diff --git a/.github/scripts/repository-check.mjs b/.github/scripts/repository-check.mjs index 33b5b351e..02383bdcd 100644 --- a/.github/scripts/repository-check.mjs +++ b/.github/scripts/repository-check.mjs @@ -188,6 +188,9 @@ try { // the exception file- and value-specific so docs and all other source remain // subject to the normal external-repository privacy gate. const reviewedRepositoryFixtures = new Map([ + ["docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-interrupted-motion-macro-integra.patch", new Set([ + ["https://github", ".com/atomicdeploy/PCController"].join(""), + ])], ["Tools/Controller/README.md", new Set([ ["https://github", ".com/atomicdeploy/PCController"].join(""), ])], diff --git a/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-firmware-motion-macro-work.patch b/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-firmware-motion-macro-work.patch new file mode 100644 index 000000000..7f78f1ca1 --- /dev/null +++ b/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-firmware-motion-macro-work.patch @@ -0,0 +1,1925 @@ +From 3caedc92f20a8b1544583c643365fa826bcb93ad Mon Sep 17 00:00:00 2001 +From: Atomic Deploy +Date: Mon, 24 Aug 2026 21:16:36 +0330 +Subject: [PATCH] checkpoint: preserve firmware motion macro work + +--- + PCController.ino | 6 + + PCControllerLocalLib.cpp | 2 + + PCControllerProject.cpp | 4 + + Project/ControllerEvents.h | 29 +--- + Project/Firmware/ControllerContext.inc.h | 28 ++++ + Project/Firmware/ControllerUtilities.inc.h | 5 + + Project/Firmware/FrontPanelRuntime.inc.h | 93 ++++++++--- + Project/Firmware/LifecycleRuntime.inc.h | 18 ++- + Project/Firmware/ProtocolRuntime.inc.h | 91 ++++++++++- + Project/Firmware/RadioRuntime.inc.h | 18 ++- + Project/Firmware/SensorRuntime.inc.h | 6 + + Project/FrontPanelModel.h | 58 ++++++- + Project/IlluminationController.cpp | 5 + + Project/IlluminationController.h | 32 ++++ + Project/MacroAction.h | 16 +- + .../{MacroActions.def => MacroActions.inc.h} | 5 +- + Project/MacroQueue.cpp | 99 +++++++++--- + Project/MacroQueue.h | 33 +++- + Project/ProtocolContract.h | 42 +++++ + Project/PwmController.cpp | 4 + + Project/PwmController.h | 40 +++++ + Project/StatusLedController.cpp | 5 + + Project/StatusLedController.h | 35 +++++ + Project/UartProtocol.h | 2 - + ProjectConfig.h | 32 ++++ + .../Controller/internal/programmer/compile.go | 2 +- + .../internal/programmer/programmer_test.go | 6 +- + .../tests/firmware_controls_test.cpp | 13 ++ + .../tests/firmware_macro_queue_test.cpp | 148 ++++++++++++++++-- + 29 files changed, 765 insertions(+), 112 deletions(-) + rename Project/{MacroActions.def => MacroActions.inc.h} (77%) + +diff --git a/PCController.ino b/PCController.ino +index 3bdc4dc1..2782a03c 100644 +--- a/PCController.ino ++++ b/PCController.ino +@@ -6,7 +6,9 @@ + #include + + #include "LocalLib/BoardPins.h" ++#if PCCONTROLLER_ENABLE_DS18B20 + #include "LocalLib/DallasTemperatureBus.h" ++#endif + #include "LocalLib/Keys.h" + #include "LocalLib/ModeManager.h" + #include "LocalLib/SevenSegments.h" +@@ -19,10 +21,14 @@ + #include "Project/CompactI2c.h" + #include "Project/FrontPanelModel.h" + #include "Project/IlluminationController.h" ++#if PCCONTROLLER_ENABLE_INA219 + #include "Project/Ina219Sensor.h" ++#endif + #include "Project/MacroQueue.h" + #include "Project/PwmController.h" ++#if PCCONTROLLER_ENABLE_PCA9685 + #include "Project/PwmExpanderDriver.h" ++#endif + #include "Project/RelayController.h" + #include "Project/RemoteLearningStore.h" + #include "Project/ResetTelemetry.h" +diff --git a/PCControllerLocalLib.cpp b/PCControllerLocalLib.cpp +index e4d63cbf..1412b538 100644 +--- a/PCControllerLocalLib.cpp ++++ b/PCControllerLocalLib.cpp +@@ -1,6 +1,8 @@ + // Arduino compiles root translation units but not arbitrary root subfolders. + // Keep the LocalLib domain layout while building each implementation once. ++#if PCCONTROLLER_ENABLE_DS18B20 + #include "LocalLib/DallasTemperatureBus.cpp" ++#endif + #include "LocalLib/I2cLcd.cpp" + #include "LocalLib/Keys.cpp" + #include "LocalLib/SevenSegments.cpp" +diff --git a/PCControllerProject.cpp b/PCControllerProject.cpp +index 1e64cf85..24544daf 100644 +--- a/PCControllerProject.cpp ++++ b/PCControllerProject.cpp +@@ -5,10 +5,14 @@ + #include "Project/ControllerEvents.cpp" + #include "Project/FeedbackMelodies.cpp" + #include "Project/IlluminationController.cpp" ++#if PCCONTROLLER_ENABLE_INA219 + #include "Project/Ina219Sensor.cpp" ++#endif + #include "Project/MacroQueue.cpp" ++#if PCCONTROLLER_ENABLE_PCA9685 + #include "Project/PwmController.cpp" + #include "Project/PwmExpanderDriver.cpp" ++#endif + #include "Project/RelayController.cpp" + #include "Project/RemoteLearningStore.cpp" + #include "Project/ResetTelemetry.cpp" +diff --git a/Project/ControllerEvents.h b/Project/ControllerEvents.h +index f11d6504..4959becb 100644 +--- a/Project/ControllerEvents.h ++++ b/Project/ControllerEvents.h +@@ -5,26 +5,8 @@ + #include "MacroAction.h" + #include "UartProtocol.h" + +-// ControllerEventType identifies one asynchronous native event payload shape. +-enum class ControllerEventType : uint8_t { +- Key = 1, +- Door = 2, +- Bluetooth = 3, +- PwmChannel = 4, +- RfLearned = 5, +- Macro = 6, +- Fault = 7, +- RfReceived = 8, +- RfLearning = 9, +- Relay = 10, +- Alert = 11, +- // Host-routed page navigation: [type, target, ASCII page...]. Target is +- // 0=all, 1=WebUI, 2=TUI. Firmware may emit it without knowing host UI APIs. +- AppNavigation = 12, +- // Accepted ordinary opcode evidence. UartProtocol appends the exact MCU +- // timestamp: [type, source, opcode, length, payload..., micros LE32]. +- Action = 13, +-}; ++using ControllerEventType = ControllerProtocol::EventType; ++using InputEventSource = ControllerProtocol::InputEventSource; + + // ControllerAlertKind classifies board-generated warning notifications. + enum class ControllerAlertKind : uint8_t { +@@ -32,13 +14,6 @@ enum class ControllerAlertKind : uint8_t { + Hot = 2, + }; + +-// InputEventSource records whether a gesture originated physically, by RF, or by host. +-enum class InputEventSource : uint8_t { +- Physical = 0, +- Radio = 1, +- Host = 2, +-}; +- + // Owns the compact asynchronous event wire format. Keeping event encoding in + // one domain prevents menu, RF, sensor, and macro code from drifting apart. + class ControllerEvents { +diff --git a/Project/Firmware/ControllerContext.inc.h b/Project/Firmware/ControllerContext.inc.h +index 7be7bca8..764682e8 100644 +--- a/Project/Firmware/ControllerContext.inc.h ++++ b/Project/Firmware/ControllerContext.inc.h +@@ -47,11 +47,19 @@ static_assert(sizeof(TelemetryPayload) == ControllerProtocol::MaximumPayload, + "Native telemetry wire layout changed"); + + // Long-lived hardware drivers and protocol-domain coordinators. ++#if PCCONTROLLER_ENABLE_PCA9685 + PwmExpanderDriver pwmDriver(BoardPins::PwmAddress); + PwmController pwm(pwmDriver); ++#else ++PwmController pwm; ++#endif ++#if PCCONTROLLER_ENABLE_INA219 + Ina219Sensor ina219(BoardPins::Ina219Address); ++#endif ++#if PCCONTROLLER_ENABLE_DS18B20 + DallasTemperatureBus temperatureBus(BoardPins::OneWireData); + Ds18b20Address temperatureAddresses[2]; ++#endif + RCSwitch radioReceiver; + RCSwitch radioTransmitter; + RelayController relays(shiftRegisters); +@@ -81,10 +89,22 @@ int16_t smoothTemperature(int16_t filtered, int16_t sample) { + } + + // Peripheral availability, asynchronous conversion, and RF-learning state. ++#if PCCONTROLLER_ENABLE_INA219 + bool ina219Available = false; ++#else ++constexpr bool ina219Available = false; ++#endif ++#if PCCONTROLLER_ENABLE_PCA9685 + bool pwmAvailable = false; ++#else ++constexpr bool pwmAvailable = false; ++#endif ++#if PCCONTROLLER_ENABLE_DS18B20 + uint8_t temperatureAddressCount = 0; + bool temperatureConversionPending = false; ++#else ++constexpr uint8_t temperatureAddressCount = 0; ++#endif + bool learningActive = false; + uint8_t learningMode = RF_LEARN_INDEFINITE; + uint8_t learningTotalSeconds = 0; +@@ -115,6 +135,7 @@ uint8_t motionPressedMask = 0; + uint32_t flashMessageEndsAt = 0; + #if PCCONTROLLER_ENABLE_MACRO_CAPTURE + uint8_t nextLocalMacroId = 1; ++bool suppressLocalMacroClassification = false; + #endif + // Presentation visibility/order is configured independently over UART and is + // not rolled back by a local edit. The late display-options byte is captured +@@ -137,8 +158,12 @@ uint16_t streamPeriodMs = 0; + // Cooperative task timestamps; each service owns one cadence/deadline. + uint32_t lastShiftPollAt = 0; + uint32_t lastDisplayRefreshAt = 0; ++#if PCCONTROLLER_ENABLE_INA219 + uint32_t lastIna219SampleAt = 0; ++#endif ++#if PCCONTROLLER_ENABLE_DS18B20 + uint32_t lastTemperatureRequestAt = 0; ++#endif + uint32_t lastTelemetryAt = 0; + + // RF momentary actions expire locally even if repeats or the host disappear. +@@ -196,6 +221,9 @@ constexpr uint8_t HOST_PANEL_CAPTURED = 1U << 4; + + // Cross-domain entry points required before their ordered implementation fragments. + void handleMenuAction(uint8_t action, bool fromRemote = false); ++void applyKeyGesture(uint8_t bit, KeyEvent event, ++ InputEventSource source = InputEventSource::Physical, ++ bool emitEvidence = true); + void setMenuPage(uint8_t page); + void sendTelemetry(uint8_t sequence); + void endLearning(uint8_t state, int8_t feedback); +diff --git a/Project/Firmware/ControllerUtilities.inc.h b/Project/Firmware/ControllerUtilities.inc.h +index 836fb208..6021a242 100644 +--- a/Project/Firmware/ControllerUtilities.inc.h ++++ b/Project/Firmware/ControllerUtilities.inc.h +@@ -161,6 +161,7 @@ uint32_t readU32(const uint8_t *buffer) { + void safeStopMacroOutputs() { + relays.allOff(now); + pwm.clearMask(PwmChannels::UserTestMask); ++ buzzer.stop(); + hostLcdFlags &= static_cast(~HOST_STATUS_OVERRIDE); + statusLeds.cancelEffect(); + } +@@ -174,8 +175,12 @@ bool hostUnavailable() { + + // Collapses both named temperature channels into the safety warning state. + bool temperatureHot() { ++#if PCCONTROLLER_ENABLE_DS18B20 + return sensors.temperatureCentiC[0] >= HOT_TEMPERATURE_CENTI_C || + sensors.temperatureCentiC[1] >= HOT_TEMPERATURE_CENTI_C; ++#else ++ return false; ++#endif + } + + // Composes the compact availability/activity bitmap used by StatusResponse. +diff --git a/Project/Firmware/FrontPanelRuntime.inc.h b/Project/Firmware/FrontPanelRuntime.inc.h +index 24a2ca8d..00e9ea14 100644 +--- a/Project/Firmware/FrontPanelRuntime.inc.h ++++ b/Project/Firmware/FrontPanelRuntime.inc.h +@@ -160,6 +160,12 @@ void moveMenuCategory(bool forward, uint32_t at) { + // Activates a stable page and optionally persists it as the boot default. + void setMenuPage(uint8_t page) { + menuPage = canonicalFrontPanelPage(page); ++ if (!frontPanelPageCompiled(menuPage)) { ++ menuPage = PAGE_MOTION; ++ } ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ suppressLocalMacroClassification = false; ++#endif + #if PCCONTROLLER_MENU_HIERARCHY + menuTreeState = menuCategory(menuPage); + #endif +@@ -507,6 +513,20 @@ bool handleMotionPanelAction(uint8_t action, bool fromRemote, + return accepted; + } + ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE && \ ++ !PCCONTROLLER_UNIFIED_PAGE_IDENTIFIES_KEYS ++void beginLocalMacroCapture() { ++ const uint8_t id = nextLocalMacroId; ++ if (!macroPlayback.beginCapture(id, micros())) { ++ return; ++ } ++ ++nextLocalMacroId; ++ if (nextLocalMacroId == 0) { ++ nextLocalMacroId = 1; ++ } ++} ++#endif ++ + // Dispatches physical, RF, or host navigation through the modal menu state machine. + void handleMenuAction(uint8_t action, bool fromRemote) { + if (action > MENU_INCREASE) { +@@ -569,23 +589,13 @@ void handleMenuAction(uint8_t action, bool fromRemote) { + identifiedKeyEndsAt = actionNow + 900; + #else + } else if (intent == UnifiedInputIntent::Macro) { +-#if PCCONTROLLER_ENABLE_MACRO_CAPTURE +- if (macroPlayback.recording()) { +- macroPlayback.finishCapture(); +- } else if (macroPlayback.captured()) { +- macroPlayback.playCapture(micros()); +- } else { +- const uint8_t id = nextLocalMacroId++; +- if (nextLocalMacroId == 0) { +- nextLocalMacroId = 1; +- } +- macroPlayback.beginCapture(id, micros()); +- } +-#endif ++ // Local capture lifecycle is handled from the physical classified key ++ // path below. RF/host MenuAction K3 is intentionally a no-op here. + } else if (relays.motionAllowed()) { + modeManager.transitionTo(MODE_MOTION_CONTROL); + } else { + buzzer.error(); ++ } + #endif + menuFeedback(fromRemote); + return; +@@ -892,9 +902,37 @@ void handleMenuAction(uint8_t action, bool fromRemote) { + // safety path in protocol dispatch. Primary actions run on Down; Click remains + // deferred classification only and must never sit on the control path. + void applyKeyGesture(uint8_t bit, KeyEvent event, +- InputEventSource source = InputEventSource::Physical, +- bool emitEvidence = true) { ++ InputEventSource source, bool emitEvidence) { + const ProgramMode mode = modeManager.current(); ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE && \ ++ !PCCONTROLLER_UNIFIED_PAGE_IDENTIFIES_KEYS ++ if (source == InputEventSource::Physical && mode == MODE_MOTION && ++ bit == BoardPins::KeyDecrease) { ++ const UnifiedMacroGesture gesture = unifiedMacroGesture( ++ event, macroPlayback.captured(), suppressLocalMacroClassification); ++ switch (gesture) { ++ case UnifiedMacroGesture::ImmediateCapture: ++ suppressLocalMacroClassification = true; ++ if (macroPlayback.recording()) { ++ macroPlayback.finishCapture(); ++ } else { ++ beginLocalMacroCapture(); ++ } ++ return; ++ case UnifiedMacroGesture::Replay: ++ macroPlayback.playCapture(micros()); ++ return; ++ case UnifiedMacroGesture::ReplaceCapture: ++ beginLocalMacroCapture(); ++ return; ++ case UnifiedMacroGesture::SuppressClassification: ++ suppressLocalMacroClassification = false; ++ return; ++ case UnifiedMacroGesture::None: ++ return; ++ } ++ } ++#endif + const bool momentary = mode == MODE_MOTION_CONTROL || + (mode == MODE_USER_RELAY_CONTROL && + userRelayBehavior && bit == BoardPins::KeyIncrease); +@@ -950,7 +988,8 @@ void applyKeyGesture(uint8_t bit, KeyEvent event, + } + } + } else if (keyEventRunsPrimaryAction(event) && +- (event != KeyEvent::HoldRepeat || mode != MODE_KEYS)) { ++ (event != KeyEvent::HoldRepeat || ++ (mode != MODE_KEYS && mode != MODE_MOTION))) { + #if PCCONTROLLER_ENABLE_MACRO_CAPTURE + const bool recordingBefore = macroPlayback.recording(); + #endif +@@ -1002,14 +1041,28 @@ void serviceMotionExit(uint32_t at) { + motionExitStartedAt = 0; + return; + } +- if (motionControl) { +- relays.allOff(at); +- } + if (motionExitStartedAt == 0xFFFFFFFFUL) { + return; + } + if (motionExitStartedAt == 0) { +- motionExitStartedAt = at; ++ for (uint8_t side = 0; side < 2; ++side) { ++ const ::RelaySide relaySide = static_cast<::RelaySide>(side); ++ const RelaySideStatus status = relays.sideStatus(relaySide); ++ if (!status.requestedEnabled && !status.appliedEnabled && ++ status.phase == RelaySequencePhase::Idle) { ++ continue; ++ } ++ relays.stopSide(relaySide, at); ++ const uint8_t payload[] = {side, 0}; ++ acceptedAction(InputEventSource::Physical, ++ ControllerProtocol::RelaySide, payload, ++ sizeof(payload)); ++ } ++ relays.allOff(at); ++ acceptedAction(InputEventSource::Physical, ++ ControllerProtocol::RelayAllOff, nullptr, 0); ++ motionPressedMask = 0; ++ motionExitStartedAt = at == 0 ? 1 : at; + } else if (static_cast(at - motionExitStartedAt) >= + static_cast( + settingsStore.values().motionExitHoldSeconds()) * 1000U) { +diff --git a/Project/Firmware/LifecycleRuntime.inc.h b/Project/Firmware/LifecycleRuntime.inc.h +index 6d5508b6..0e53a3a5 100644 +--- a/Project/Firmware/LifecycleRuntime.inc.h ++++ b/Project/Firmware/LifecycleRuntime.inc.h +@@ -28,7 +28,10 @@ static inline __attribute__((always_inline)) void initializeController() { + loadIlluminationSettings(); + const ControllerSettings &settings = settingsStore.values(); + const bool programming = settings.programmingMode(); +- menuPage = settings.defaultMenuPage; ++ menuPage = canonicalFrontPanelPage(settings.defaultMenuPage); ++ if (!frontPanelPageCompiled(menuPage)) { ++ menuPage = PAGE_MOTION; ++ } + buzzer.setMuted(BuildForcesSilent || settings.silent() || programming); + streamPeriodMs = settings.streamPeriodMs; + display.begin(programming || systemInputs.doorOpen() +@@ -47,6 +50,7 @@ static inline __attribute__((always_inline)) void initializeController() { + i2cBus.begin(); + // Reset the TWI peripheral if any state remains blocked for 25 ms. + i2cBus.setWireTimeout(25000UL, true); ++#if PCCONTROLLER_ENABLE_PCA9685 + pwmAvailable = pwmDriver.begin(); + if (pwmAvailable) { + pwmAvailable = +@@ -54,9 +58,12 @@ static inline __attribute__((always_inline)) void initializeController() { + static_cast(BoardPins::PwmFrequencyHz)) && + normalizePwmMode2(); + } +- ++#endif ++#if PCCONTROLLER_ENABLE_INA219 + ina219Available = ina219.begin(); ++#endif + } ++#if PCCONTROLLER_ENABLE_PCA9685 + pwm.begin(pwmAvailable, startupNow); + if (programming) { + // A durable programming latch must not briefly restore an On/Auto light +@@ -67,14 +74,17 @@ static inline __attribute__((always_inline)) void initializeController() { + illumination.begin(pwm, systemInputs.doorOpen(), startupNow); + statusLeds.begin(pwm, programming ? 0 : settings.statusBrightness, startupNow, + !programming); ++#endif + applyStoredSettings(startupNow); + restoreStoredOutputs(startupNow); + appProtocol.service(); + wdt_reset(); + ++#if PCCONTROLLER_ENABLE_DS18B20 + temperatureBus.begin(); + discoverTemperatureSensors(); + requestTemperatures(startupNow); ++#endif + appProtocol.service(); + wdt_reset(); + +@@ -135,10 +145,14 @@ static inline __attribute__((always_inline)) void serviceController() { + } + serviceShiftRegisterAndKeys(loopNow); + serviceRemoteMomentary(loopNow); ++#if PCCONTROLLER_ENABLE_DS18B20 + serviceTemperatures(loopNow); ++#endif ++#if PCCONTROLLER_ENABLE_INA219 + if (!i2cReserved) { + sampleIna219(loopNow); + } ++#endif + programService(loopNow); + + // Edge state suppresses duplicate events while a cadence allows repeated +diff --git a/Project/Firmware/ProtocolRuntime.inc.h b/Project/Firmware/ProtocolRuntime.inc.h +index 82db4687..46a01ef4 100644 +--- a/Project/Firmware/ProtocolRuntime.inc.h ++++ b/Project/Firmware/ProtocolRuntime.inc.h +@@ -6,17 +6,30 @@ + // Reports build identity, record shape, and independently testable capabilities. + void sendHello(uint8_t sequence) { + constexpr uint32_t capabilities = ++#if PCCONTROLLER_ENABLE_INA219 + (1UL << 0) | // INA219 ++#endif ++#if PCCONTROLLER_ENABLE_DS18B20 + (1UL << 1) | // two DS18B20 sensors ++#endif ++#if PCCONTROLLER_ENABLE_PCA9685 + (1UL << 2) | // 16-channel PWM ++#endif + (1UL << 3) | // relay safety controller + (1UL << 4) | // 433 MHz RX/TX, learning, and action mapping + (1UL << 5) | // TM1637 +- (1UL << 6) | // I2C LCD ++#if PCCONTROLLER_ENABLE_I2C_LCD ++ (1UL << 6) | // MCU-rendered I2C LCD ++#endif + (1UL << 7) | // addressable LEDs + (1UL << 8) | // persistent settings + (1UL << 9) | // menu remote control ++#if PCCONTROLLER_ENABLE_DS18B20 + (1UL << 10) | // named temperature identities ++#endif ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ (1UL << 11) | // board-local bounded capture/replay/export ++#endif + (1UL << 12) | // host display text and asynchronous events + (1UL << 13) | // exact front-panel snapshot + (1UL << 14) | // host-injected key lifecycle; Down acts immediately +@@ -29,7 +42,7 @@ void sendHello(uint8_t sequence) { + (1UL << 19) | // host-captured front-panel session (DisplayText targets 3/4) + (1UL << 20) | // status bit 12 means buzzer queue/voice is busy + (1UL << 21) | // EEPROM-selectable 1..255 ms motion break time +- (1UL << 22) | // MCU-timed events/ACKs and queued macro schema 2 ++ (1UL << 22) | // MCU-timed events/ACKs and queued macro schema 3 + #if PCCONTROLLER_MENU_LAYOUT_PROTOCOL + (1UL << 23) | // persistent visible-mask and stable-ID rank permutation + #endif +@@ -37,21 +50,50 @@ void sendHello(uint8_t sequence) { + (1UL << 25) | // scheduled segment once/loop/interval presentation + (1UL << 26) | // unsolicited changed-only TM1637 state frames + (1UL << 27) | // unsolicited buzzer frequency/duration frames ++#if PCCONTROLLER_ENABLE_PCA9685 + (1UL << 28) | // MCU-owned procedural status LED effects + (1UL << 29) | // unsolicited rendered status LED state frames + (1UL << 30) | // EEPROM-resident condition status profiles ++#endif + (1UL << 31) | // checksum-backed operator board name (up to 8 ASCII chars) + 0; +- // HelloPayload is the fixed build identity and capability response. ++ constexpr uint8_t featureProfile = ++#if PCCONTROLLER_UNIFIED_PAGE_IDENTIFIES_KEYS ++ ControllerProtocol::KeyDiagnosticProfile; ++#elif PCCONTROLLER_ENABLE_INA219 && PCCONTROLLER_ENABLE_DS18B20 && \ ++ PCCONTROLLER_ENABLE_PCA9685 && PCCONTROLLER_ENABLE_I2C_LCD ++ ControllerProtocol::FullPeripheralProfile; ++#elif !PCCONTROLLER_ENABLE_INA219 && !PCCONTROLLER_ENABLE_DS18B20 && \ ++ !PCCONTROLLER_ENABLE_PCA9685 && !PCCONTROLLER_ENABLE_I2C_LCD ++ ControllerProtocol::MotionMacroProfile; ++#else ++ ControllerProtocol::CustomFeatureProfile; ++#endif ++ constexpr uint8_t buildFlags = ++ (PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ ? ControllerProtocol::LocalMacroCapture ++ : 0) | ++ (PCCONTROLLER_UNIFIED_PAGE_IDENTIFIES_KEYS ++ ? ControllerProtocol::UnifiedPageIdentifiesKeys ++ : 0) | ++ (PCCONTROLLER_FORCE_SILENT ? ControllerProtocol::ForceSilent : 0) | ++ (PCCONTROLLER_BLANK_EEPROM_SILENT ++ ? ControllerProtocol::BlankEepromSilent ++ : 0); ++ // Schema 4 preserves the prior identity prefix and appends explicit profile ++ // truth so a host never guesses compile-time behavior from runtime absence. + struct __attribute__((packed)) HelloPayload { + uint8_t schema; + uint8_t boardKind; + uint32_t capabilities; + uint32_t buildHash; + uint32_t buildTimestamp; +- } payload = {3, 1, capabilities, ++ uint8_t featureProfile; ++ uint8_t buildFlags; ++ } payload = {4, 1, capabilities, + pgm_read_dword(&firmwareIdentity.sourceHash), +- pgm_read_dword(&firmwareIdentity.packedTimestamp)}; ++ pgm_read_dword(&firmwareIdentity.packedTimestamp), ++ featureProfile, buildFlags}; + appProtocol.send(ControllerProtocol::HelloResponse, sequence, + reinterpret_cast(&payload), + sizeof(payload)); +@@ -108,6 +150,7 @@ void sendSettings(uint8_t sequence) { + } + + // Reports all logical PWM values plus controller availability and selection. ++#if PCCONTROLLER_ENABLE_PCA9685 + void sendPwmValues(uint8_t sequence) { + uint8_t payload[34]; + uint8_t index = 0; +@@ -119,8 +162,10 @@ void sendPwmValues(uint8_t sequence) { + appProtocol.send(ControllerProtocol::PwmValuesResponse, sequence, payload, + index); + } ++#endif + + // Pages detected DS18B20 ROM identities, roles, and current readings. ++#if PCCONTROLLER_ENABLE_DS18B20 + void sendTemperatureList(uint8_t sequence) { + uint8_t payload[24] = {1, temperatureAddressCount}; + uint8_t index = 2; +@@ -135,6 +180,7 @@ void sendTemperatureList(uint8_t sequence) { + appProtocol.send(ControllerProtocol::TemperatureListResponse, sequence, + payload, index); + } ++#endif + + // Mirrors physical/host panel segments, keys, LCD metadata, and macro progress. + void sendFrontPanel(uint8_t sequence) { +@@ -471,8 +517,12 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + goto acknowledged; + + case TemperatureList: ++#if !PCCONTROLLER_ENABLE_DS18B20 ++ goto hardwareUnavailable; ++#else + sendTemperatureList(frame.sequence); + return; ++#endif + + case FrontPanelGet: + sendFrontPanel(frame.sequence); +@@ -519,6 +569,9 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + goto acknowledged; + + case PwmSet: { ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + const uint16_t value = length >= 3 ? readU16(payload + 1) : 0; + if (length < 3 || payload[0] >= PwmChannels::Count || value > 4095) { + goto badPayload; +@@ -530,15 +583,23 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + storeUserPwmValue(payload[0], value); + } + goto acknowledged; ++#endif + } + + case PwmAllOff: ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + if (!pwm.tryAllOff()) { + goto hardwareUnavailable; + } + goto acknowledged; ++#endif + + case StatusRgb: ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + if (length < 4) { + goto badPayload; + } +@@ -546,8 +607,12 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + statusLeds.setBrightness(payload[3]); + statusLeds.setCustom(payload[0], payload[1], payload[2]); + goto acknowledged; ++#endif + + case StatusEffect: ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + // [kind][RGB A][RGB B][brightness][minimum brightness][period u16] + // [repeats]. Repeats zero loops; 1..255 are MCU-counted cycles. + if (length == 1 && payload[0] == 0) { +@@ -564,8 +629,12 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + } + hostLcdFlags |= HOST_STATUS_OVERRIDE; + goto acknowledged; ++#endif + + case StatusProfileGet: { ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + if (length < 1 || payload[0] >= StatusLedController::ProfileCount) { + goto badPayload; + } +@@ -576,14 +645,19 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + appProtocol.send(ControllerProtocol::StatusProfileResponse, + frame.sequence, response, sizeof(response)); + return; ++#endif + } + + case StatusProfileSet: ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + if (length < 1 + StatusLedController::ProfilePayloadBytes || + !statusLeds.setProfile(payload[0], payload + 1, frameNow)) { + goto badPayload; + } + goto acknowledged; ++#endif + + case ProgramState: + // Only the semantic one-byte prefix is required; future appended state +@@ -601,8 +675,12 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + goto acknowledged; + + case PwmGet: ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ goto hardwareUnavailable; ++#else + sendPwmValues(frame.sequence); + return; ++#endif + + case AddressableLed: { + // [pixel 0..10, or 0xFF=fill][R][G][B][brightness]. +@@ -692,7 +770,8 @@ void handleProtocolFrame(const ControllerProtocol::Frame &frame, void *) { + goto acknowledged; + + case MenuSetPage: +- if (length < 1 || payload[0] >= PAGE_COUNT) { ++ if (length < 1 || payload[0] >= PAGE_COUNT || ++ !frontPanelPageCompiled(canonicalFrontPanelPage(payload[0]))) { + goto badPayload; + } + if (modeManager.current() == MODE_MOTION_CONTROL) { +diff --git a/Project/Firmware/RadioRuntime.inc.h b/Project/Firmware/RadioRuntime.inc.h +index 7412e7a3..7999de7a 100644 +--- a/Project/Firmware/RadioRuntime.inc.h ++++ b/Project/Firmware/RadioRuntime.inc.h +@@ -113,6 +113,7 @@ void stopRemoteMomentary(uint32_t at) { + break; + } + case RemoteActionKind::Pwm: { ++#if PCCONTROLLER_ENABLE_PCA9685 + pwm.setChannel(remoteMomentaryValue, at); + pwm.setValue(0, at); + storeUserPwmValue(remoteMomentaryValue, 0); +@@ -121,6 +122,7 @@ void stopRemoteMomentary(uint32_t at) { + acceptedAction(InputEventSource::Radio, ControllerProtocol::PwmSet, + payload, sizeof(payload)); + } ++#endif + break; + } + default: +@@ -157,15 +159,15 @@ void executeLearnedRemote(const LearnedRemote &remote, uint32_t at) { + appEvents.key(remote.actionValue, + static_cast(KeyEvent::Down), + InputEventSource::Radio, remote.id); +- handleMenuAction(remote.actionValue, true); +- { +- const uint8_t payload[] = {remote.actionValue}; +- acceptedAction(InputEventSource::Radio, +- ControllerProtocol::MenuAction, payload, +- sizeof(payload)); +- } ++ applyKeyGesture(remote.actionValue, KeyEvent::Down, ++ InputEventSource::Radio, true); + return; + case RemoteActionKind::Menu: ++ if (modeManager.current() == MODE_MOTION_CONTROL) { ++ applyKeyGesture(remote.actionValue, KeyEvent::Down, ++ InputEventSource::Radio, true); ++ return; ++ } + handleMenuAction(remote.actionValue, true); + { + const uint8_t payload[] = {remote.actionValue}; +@@ -228,6 +230,7 @@ void executeLearnedRemote(const LearnedRemote &remote, uint32_t at) { + } + return; + case RemoteActionKind::Pwm: { ++#if PCCONTROLLER_ENABLE_PCA9685 + pwm.setChannel(remote.actionValue, at); + const bool active = pwm.logicalValue(remote.actionValue) != 0; + const uint16_t value = behavior == RemoteBehavior::Momentary +@@ -247,6 +250,7 @@ void executeLearnedRemote(const LearnedRemote &remote, uint32_t at) { + remoteMomentaryValue = remote.actionValue; + remoteMomentaryEndsAt = at + 350; + } ++#endif + return; + } + case RemoteActionKind::None: +diff --git a/Project/Firmware/SensorRuntime.inc.h b/Project/Firmware/SensorRuntime.inc.h +index 6286f52e..0f995429 100644 +--- a/Project/Firmware/SensorRuntime.inc.h ++++ b/Project/Firmware/SensorRuntime.inc.h +@@ -47,6 +47,7 @@ bool prepareI2cBus() { + } + + // Restores the active-high/low MODE2 contract after PWM startup. ++#if PCCONTROLLER_ENABLE_PCA9685 + bool normalizePwmMode2() { + constexpr uint8_t expectedMode2 = PwmController::recommendedMode2(); + constexpr uint8_t PwmMode2Register = 0x01; +@@ -56,8 +57,10 @@ bool normalizePwmMode2() { + i2cBus.write(expectedMode2); + return i2cBus.endTransmission() == 0; + } ++#endif + + // Low-pass a completed INA219 sample in-place without another history buffer. ++#if PCCONTROLLER_ENABLE_INA219 + __attribute__((noinline)) int32_t + smoothInaValue(int32_t previous, int32_t sample, bool currentOrPower) { + if (previous == INVALID_I32) { +@@ -92,8 +95,10 @@ void sampleIna219(uint32_t at) { + index >= 2); + } + } ++#endif + + // Gives DS18B20 probes a deterministic ROM order before role assignment. ++#if PCCONTROLLER_ENABLE_DS18B20 + void sortTemperatureAddresses() { + if (temperatureAddressCount < 2 || + memcmp(temperatureAddresses[0], temperatureAddresses[1], 8) <= 0) { +@@ -193,3 +198,4 @@ void serviceTemperatures(uint32_t at) { + requestTemperatures(at); + } + } ++#endif +diff --git a/Project/FrontPanelModel.h b/Project/FrontPanelModel.h +index 2685267c..3179b925 100644 +--- a/Project/FrontPanelModel.h ++++ b/Project/FrontPanelModel.h +@@ -2,6 +2,9 @@ + + #include + ++#include "../LocalLib/Keys.h" ++#include "../ProjectConfig.h" ++ + // Physical, RF, and host keys share these stable four action IDs. + enum MenuAction : uint8_t { + MENU_PREVIOUS = 0, +@@ -37,7 +40,26 @@ constexpr uint8_t canonicalFrontPanelPage(uint8_t page) { + } + + constexpr bool frontPanelPageCompiled(uint8_t page) { +- return page < PAGE_COUNT && page != PAGE_KEYS; ++ if (page >= PAGE_COUNT || page == PAGE_KEYS) { ++ return false; ++ } ++#if !PCCONTROLLER_ENABLE_INA219 ++ if (page == PAGE_VOLTAGE || page == PAGE_CURRENT) { ++ return false; ++ } ++#endif ++#if !PCCONTROLLER_ENABLE_DS18B20 ++ if (page == PAGE_TLED || page == PAGE_TBT) { ++ return false; ++ } ++#endif ++#if !PCCONTROLLER_ENABLE_PCA9685 ++ if (page == PAGE_ILLUMINATION || page == PAGE_PWM || ++ page == PAGE_USER_PWM) { ++ return false; ++ } ++#endif ++ return true; + } + + // Top-level pages and modal editors consumed by ModeManager. +@@ -115,6 +137,40 @@ constexpr UnifiedInputIntent unifiedInputIntent(MenuAction action, + : UnifiedInputIntent::Motion; + } + ++// The local macro key is immediate while starting/stopping capture. Once data ++// exists it deliberately becomes a classified key: Click replays and the ++// one-shot HoldStart replaces the old capture without first playing outputs. ++enum class UnifiedMacroGesture : uint8_t { ++ None, ++ ImmediateCapture, ++ Replay, ++ ReplaceCapture, ++ SuppressClassification, ++}; ++ ++constexpr UnifiedMacroGesture unifiedMacroGesture(KeyEvent event, ++ bool hasCapture, ++ bool suppressClassification) { ++ if (event == KeyEvent::Down && !hasCapture) { ++ return UnifiedMacroGesture::ImmediateCapture; ++ } ++ const bool classified = event == KeyEvent::Click || ++ event == KeyEvent::DoubleClick || ++ event == KeyEvent::HoldStart; ++ if (classified && suppressClassification) { ++ return UnifiedMacroGesture::SuppressClassification; ++ } ++ if (!hasCapture) { ++ return UnifiedMacroGesture::None; ++ } ++ if (event == KeyEvent::Click || event == KeyEvent::DoubleClick) { ++ return UnifiedMacroGesture::Replay; ++ } ++ return event == KeyEvent::HoldStart ++ ? UnifiedMacroGesture::ReplaceCapture ++ : UnifiedMacroGesture::None; ++} ++ + // One immutable mapping is consumed by physical, injected, and native tests. + struct MotionKeyBinding { + uint8_t side; +diff --git a/Project/IlluminationController.cpp b/Project/IlluminationController.cpp +index c34ff11b..d4c34032 100644 +--- a/Project/IlluminationController.cpp ++++ b/Project/IlluminationController.cpp +@@ -1,5 +1,7 @@ + #include "IlluminationController.h" + ++#if PCCONTROLLER_ENABLE_PCA9685 ++ + #include "PwmController.h" + #include "TransitionMath.h" + +@@ -98,3 +100,6 @@ IlluminationController::targetBrightness(bool doorOpen) const { + } + return offBrightness_; + } ++#else ++IlluminationController illumination; ++#endif +diff --git a/Project/IlluminationController.h b/Project/IlluminationController.h +index 54f6bd45..8d7256a7 100644 +--- a/Project/IlluminationController.h ++++ b/Project/IlluminationController.h +@@ -2,6 +2,8 @@ + + #include + ++#include "../ProjectConfig.h" ++ + class PwmController; + + // IlluminationMode selects forced-off, forced-on, or door-driven brightness. +@@ -12,6 +14,7 @@ enum class IlluminationMode : uint8_t { + }; + + // Drives enclosure PWM toward Off/Auto/On targets with a nonblocking fade. ++#if PCCONTROLLER_ENABLE_PCA9685 + class IlluminationController { + public: + // Starts from the electrically observed door target without a startup jump. +@@ -40,6 +43,35 @@ private: + PwmController *pwm_ = nullptr; + bool initialized_ = false; + }; ++#else ++class IlluminationController { ++public: ++ void begin(PwmController &, bool doorOpen, uint32_t = millis()) { ++ currentBrightness_ = targetBrightness(doorOpen); ++ } ++ void service(bool, bool, uint32_t = millis()) {} ++ void setMode(IlluminationMode mode) { mode_ = mode; } ++ IlluminationMode mode() const { return mode_; } ++ void setOnBrightness(uint8_t value) { onBrightness_ = value; } ++ void setOffBrightness(uint8_t value) { offBrightness_ = value; } ++ uint8_t onBrightness() const { return onBrightness_; } ++ uint8_t offBrightness() const { return offBrightness_; } ++ uint8_t currentBrightness() const { return currentBrightness_; } ++ uint8_t targetBrightness(bool doorOpen) const { ++ return mode_ == IlluminationMode::On ++ ? onBrightness_ ++ : (mode_ == IlluminationMode::Auto && doorOpen ++ ? onBrightness_ ++ : offBrightness_); ++ } ++ ++private: ++ IlluminationMode mode_ = IlluminationMode::Auto; ++ uint8_t onBrightness_ = 128; ++ uint8_t offBrightness_ = 0; ++ uint8_t currentBrightness_ = 0; ++}; ++#endif + + // illumination is the single enclosure-light transition controller. + extern IlluminationController illumination; +diff --git a/Project/MacroAction.h b/Project/MacroAction.h +index ee271e50..a4b3e9fa 100644 +--- a/Project/MacroAction.h ++++ b/Project/MacroAction.h +@@ -22,7 +22,7 @@ constexpr uint8_t payloadLength(uint8_t opcode) { + #define PCCONTROLLER_MACRO_ACTION(name, captureLength) \ + case name: \ + return captureLength; +-#include "MacroActions.def" ++#include "MacroActions.inc.h" + #undef PCCONTROLLER_MACRO_ACTION + default: + return 0xFF; +@@ -33,7 +33,7 @@ constexpr bool playbackAllowed(uint8_t opcode) { + using namespace ControllerProtocol; + switch (opcode) { + #define PCCONTROLLER_MACRO_ACTION(name, captureLength) case name: +-#include "MacroActions.def" ++#include "MacroActions.inc.h" + #undef PCCONTROLLER_MACRO_ACTION + return true; + default: +@@ -41,6 +41,18 @@ constexpr bool playbackAllowed(uint8_t opcode) { + } + } + ++// Fixed-shape actions must use their exact canonical payload on raw playback. ++// Variable-shape rows remain bounded by the native frame and are validated by ++// the ordinary opcode dispatcher immediately before touching hardware. ++constexpr bool validPlaybackPayload(uint8_t opcode, uint8_t payloadBytes) { ++ if (!playbackAllowed(opcode) || ++ payloadBytes > ControllerProtocol::MaximumPayload) { ++ return false; ++ } ++ const uint8_t fixed = payloadLength(opcode); ++ return fixed == 0xFF || payloadBytes == fixed; ++} ++ + constexpr bool recordable(uint8_t opcode, uint8_t availablePayload) { + const uint8_t required = payloadLength(opcode); + return required != 0xFF && availablePayload >= required && +diff --git a/Project/MacroActions.def b/Project/MacroActions.inc.h +similarity index 77% +rename from Project/MacroActions.def +rename to Project/MacroActions.inc.h +index 7e93172f..b4b517ba 100644 +--- a/Project/MacroActions.def ++++ b/Project/MacroActions.inc.h +@@ -1,8 +1,9 @@ + // name, fixed board-capture payload length (0xFF = playback-only/variable) + // + // This file is deliberately an X-macro/data source without include guards. +-// C++, VirtualBoard, and the Go contract generator consume these exact rows so +-// the raw macro allow-list cannot drift among execution surfaces. ++// Its header extension makes Arduino copy it into the compiled sketch. C++, ++// VirtualBoard, and the Go contract generator consume these exact rows so the ++// raw macro allow-list cannot drift among execution surfaces. + PCCONTROLLER_MACRO_ACTION(Buzzer, 4) + PCCONTROLLER_MACRO_ACTION(PwmSet, 3) + PCCONTROLLER_MACRO_ACTION(PwmAllOff, 0) +diff --git a/Project/MacroQueue.cpp b/Project/MacroQueue.cpp +index fce39dcb..a65389fd 100644 +--- a/Project/MacroQueue.cpp ++++ b/Project/MacroQueue.cpp +@@ -34,24 +34,33 @@ bool MacroQueue::recordReady() const { + return used_ >= 6 && used_ >= static_cast(6 + peek(5)); + } + +-bool MacroQueue::bufferedRecordsValid() const { ++uint32_t read32(const uint8_t *value) { ++ return static_cast(value[0]) | ++ static_cast(value[1]) << 8 | ++ static_cast(value[2]) << 16 | ++ static_cast(value[3]) << 24; ++} ++ ++bool MacroQueue::bufferedRecordsValid(uint16_t completeSteps) const { + uint16_t offset = 0; ++ uint16_t countedSteps = wire_.report.executedSteps; + while (static_cast(used_) - offset >= + MacroAction::RecordHeaderBytes) { + const uint8_t opcode = peek(static_cast(offset + 4U)); + const uint8_t payloadLength = peek(static_cast(offset + 5U)); +- if (!MacroAction::playbackAllowed(opcode) || +- payloadLength > ControllerProtocol::MaximumPayload) { ++ if (!MacroAction::validPlaybackPayload(opcode, payloadLength) || ++ peekU32(static_cast(offset)) > 0x7FFFFFFFUL) { + return false; + } + const uint16_t recordLength = + MacroAction::RecordHeaderBytes + payloadLength; + if (static_cast(used_) - offset < recordLength) { +- return true; // a valid header with a split payload tail ++ break; // a valid header with a split payload tail + } ++ ++countedSteps; + offset += recordLength; + } +- return true; ++ return countedSteps == completeSteps; + } + + void MacroQueue::appendByte(uint8_t value) { +@@ -64,7 +73,9 @@ void MacroQueue::preserveCaptureSnapshot() { + capturedHead_ = head_; + capturedUsed_ = used_; + capturedSteps_ = retainedSteps_; +- capturedData_ = retainedSteps_ != 0; ++ // Even an empty recording is a terminal, recoverable lifecycle. This lets a ++ // connected host finalize/discard it instead of remaining Recording forever. ++ capturedData_ = true; + } + + void MacroQueue::restoreCaptureSnapshot() { +@@ -132,7 +143,7 @@ bool MacroQueue::handle(const ControllerProtocol::Frame &frame) { + // duplicating tolerance/statistics code in AVR flash. + // Duration remains host-owned because the hosted front-panel macro page + // already has the complete library and this saves scarce AVR flash. +- if (length < 5 || payload[0] != Schema) { ++ if (length < 5 || payload[0] != Schema || read16(payload + 3) == 0) { + protocol_.sendError(frame.sequence, frame.opcode, + ControllerProtocol::BadPayload); + return true; +@@ -184,6 +195,42 @@ bool MacroQueue::handle(const ControllerProtocol::Frame &frame) { + sendCaptureChunk(frame.sequence, offset); + return true; + } ++ // ACK_EXPORT [4, id, capture-start micros LE32]. Retained data remains ++ // replayable/fetchable; the terminal state prevents duplicate import after ++ // reconnect or a host process restart. ++ if (length == 6 && payload[0] == 4) { ++ if (!captured() || payload[1] != report.id || ++ read32(payload + 2) != report.startedAtUs) { ++ protocol_.sendError(frame.sequence, frame.opcode, ++ ControllerProtocol::BadPayload); ++ return true; ++ } ++ options_ |= CaptureExportAcknowledged; ++ report.state = Exported; ++ protocol_.sendAck(frame.sequence, frame.opcode); ++ sendStatus(ControllerProtocol::Event, 0); ++ return true; ++ } ++ // CLEAR_CAPTURE [5, id, capture-start micros LE32] is an explicit user ++ // action, not part of import. It requests a safe output stop before deleting ++ // the identity-guarded retained ring. ++ if (length == 6 && payload[0] == 5) { ++ if (!captured() || payload[1] != report.id || ++ read32(payload + 2) != report.startedAtUs) { ++ protocol_.sendError(frame.sequence, frame.opcode, ++ ControllerProtocol::BadPayload); ++ return true; ++ } ++ head_ = used_ = capturedHead_ = capturedUsed_ = 0; ++ retainedSteps_ = capturedSteps_ = 0; ++ capturedData_ = capturePlayback_ = false; ++ options_ = 0; ++ safeStopRequested_ = true; ++ report.state = Idle; ++ protocol_.sendAck(frame.sequence, frame.opcode); ++ sendStatus(ControllerProtocol::Event, 0); ++ return true; ++ } + #endif + if (length != 0 && payload[0] == 2) { + sendStatus(ControllerProtocol::MacroStatusResponse, frame.sequence); +@@ -208,7 +255,7 @@ bool MacroQueue::handle(const ControllerProtocol::Frame &frame) { + } + + // APPEND [0][stream offset LE16][complete step index LE16][bytes...]. +- if (length < 6 || payload[0] != 0 || !active() || ++ if (length < 6 || payload[0] != 0 || !hostDependent() || + read16(payload + 1) != report.acceptedBytes || + read16(payload + 3) < report.acceptedSteps || + read16(payload + 3) > report.totalSteps || +@@ -221,7 +268,7 @@ bool MacroQueue::handle(const ControllerProtocol::Frame &frame) { + for (uint8_t index = 5; index < length; ++index) { + appendByte(payload[index]); + } +- if (!bufferedRecordsValid()) { ++ if (!bufferedRecordsValid(read16(payload + 3))) { + fail(); + protocol_.sendError(frame.sequence, frame.opcode, + ControllerProtocol::BadPayload); +@@ -245,19 +292,13 @@ bool MacroQueue::dequeueDue(ControllerProtocol::Frame &frame) { + return false; + } + const uint8_t payloadLength = peek(5); +- if (payloadLength > ControllerProtocol::MaximumPayload || +- !MacroAction::playbackAllowed(peek(4))) { ++ if (!MacroAction::validPlaybackPayload(peek(4), payloadLength)) { + ++report.dispatchErrors; + fail(); + return false; + } + const uint32_t actual = micros(); + uint32_t due = peekU32(0); +-#if PCCONTROLLER_ENABLE_MACRO_CAPTURE +- if (capturePlayback_) { +- due -= capturePlaybackOffsetUs_; +- } +-#endif + const int32_t error = + static_cast(actual - (startedAtUs_ + due)); + if (error < 0) { +@@ -296,6 +337,12 @@ void MacroQueue::completeStep(bool succeeded) { + ++report.dispatchErrors; + } + if (report.executedSteps == report.totalSteps) { ++ const bool exportedCapture = ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ capturePlayback_ && (options_ & CaptureExportAcknowledged) != 0; ++#else ++ false; ++#endif + report.state = used_ == 0 && report.dispatchErrors == 0 ? Completed + : Failed; + safeStopRequested_ = report.state == Failed; +@@ -311,6 +358,11 @@ void MacroQueue::completeStep(bool succeeded) { + head_ = used_ = 0; + #endif + sendStatus(ControllerProtocol::Event, 0); ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ if (exportedCapture && capturedData_) { ++ report.state = Exported; ++ } ++#endif + } + } + +@@ -348,7 +400,12 @@ bool MacroQueue::active() const { + } + + bool MacroQueue::hostDependent() const { +- return wire_.report.state == Buffering || wire_.report.state == Playing; ++ return wire_.report.state == Buffering || ++ (wire_.report.state == Playing ++#if PCCONTROLLER_ENABLE_MACRO_CAPTURE ++ && !capturePlayback_ ++#endif ++ ); + } + + #if PCCONTROLLER_ENABLE_MACRO_CAPTURE +@@ -365,7 +422,6 @@ bool MacroQueue::beginCapture(uint8_t id, uint32_t atUs) { + startedAtUs_ = atUs; + head_ = used_ = 0; + retainedSteps_ = 0; +- capturePlaybackOffsetUs_ = 0; + capturePlayback_ = false; + capturedData_ = false; + options_ = 0; +@@ -385,8 +441,10 @@ bool MacroQueue::captureAction(uint8_t opcode, const uint8_t *payload, + } + const uint8_t recordLength = + static_cast(MacroAction::RecordHeaderBytes + payloadLength); ++ const uint32_t due = atUs - startedAtUs_; + Report &report = wire_.report; +- if (static_cast(used_) + recordLength > QueueCapacity) { ++ if (due > 0x7FFFFFFFUL || ++ static_cast(used_) + recordLength > QueueCapacity) { + // Never overwrite an unconsumed record. The exact retained prefix remains + // downloadable/playable; a connected host still receives this action's + // separate timestamped evidence and can preserve the complete sequence. +@@ -400,7 +458,6 @@ bool MacroQueue::captureAction(uint8_t opcode, const uint8_t *payload, + return false; + } + +- const uint32_t due = atUs - startedAtUs_; + appendByte(static_cast(due)); + appendByte(static_cast(due >> 8)); + appendByte(static_cast(due >> 16)); +@@ -442,8 +499,6 @@ bool MacroQueue::playCapture(uint32_t atUs) { + report.underruns = 0; + report.totalSteps = retainedSteps_; + startedAtUs_ = atUs; +- report.startedAtUs = atUs; +- capturePlaybackOffsetUs_ = peekU32(0); + capturePlayback_ = true; + safeStopRequested_ = false; + sendStatus(ControllerProtocol::Event, 0); +diff --git a/Project/MacroQueue.h b/Project/MacroQueue.h +index bed7ca5b..a77a86ac 100644 +--- a/Project/MacroQueue.h ++++ b/Project/MacroQueue.h +@@ -6,6 +6,16 @@ + #include "MacroAction.h" + #include "UartProtocol.h" + ++#if defined(_MSC_VER) ++#define PCCONTROLLER_PACK_PUSH __pragma(pack(push, 1)) ++#define PCCONTROLLER_PACK_POP __pragma(pack(pop)) ++#define PCCONTROLLER_PACKED ++#else ++#define PCCONTROLLER_PACK_PUSH ++#define PCCONTROLLER_PACK_POP ++#define PCCONTROLLER_PACKED __attribute__((packed)) ++#endif ++ + // Buffers ordinary protocol commands against USB/network jitter and releases + // them from the AVR microsecond clock without duplicating peripheral guards. + class MacroQueue { +@@ -13,6 +23,7 @@ public: + static constexpr uint8_t Schema = 3; + static constexpr uint8_t ExecutionSequence = 0xFE; + static constexpr uint8_t KeepOutputsOnCancel = 1U << 0; ++ static constexpr uint8_t CaptureExportAcknowledged = 1U << 7; + static constexpr uint8_t QueueSize = 128; + static constexpr uint8_t QueueCapacity = QueueSize - 1U; + +@@ -26,6 +37,7 @@ public: + Failed = 5, + Recording = 6, + Captured = 7, ++ Exported = 8, + }; + + explicit MacroQueue(ControllerProtocol::UartProtocol &protocol); +@@ -58,8 +70,10 @@ public: + private: + static constexpr uint8_t QueueMask = QueueSize - 1; + +- // Report is the synchronous macro status response payload. +- struct __attribute__((packed)) Report { ++ // Report is the synchronous macro status response payload. Keep the packing ++ // portable because this shared core is compiled by AVR-GCC and MSVC. ++ PCCONTROLLER_PACK_PUSH ++ struct PCCONTROLLER_PACKED Report { + uint8_t schema; + uint8_t state; + uint8_t id; +@@ -75,15 +89,21 @@ private: + }; + + // EventReport records MCU timing fidelity for one dispatched step. +- struct __attribute__((packed)) EventReport { ++ struct PCCONTROLLER_PACKED EventReport { + uint8_t type; + Report report; + } wire_; ++ PCCONTROLLER_PACK_POP ++ ++ static_assert(sizeof(Report) == 20, ++ "macro report wire shape changed unexpectedly"); ++ static_assert(sizeof(EventReport) == 21, ++ "macro event wire shape changed unexpectedly"); + + uint8_t peek(uint8_t offset) const; + uint32_t peekU32(uint8_t offset) const; + bool recordReady() const; +- bool bufferedRecordsValid() const; ++ bool bufferedRecordsValid(uint16_t completeSteps) const; + void appendByte(uint8_t value); + #if PCCONTROLLER_ENABLE_MACRO_CAPTURE + void sendCaptureChunk(uint8_t sequence, uint16_t offset); +@@ -102,7 +122,6 @@ private: + bool safeStopRequested_ = false; + #if PCCONTROLLER_ENABLE_MACRO_CAPTURE + uint16_t retainedSteps_ = 0; +- uint32_t capturePlaybackOffsetUs_ = 0; + uint8_t capturedHead_ = 0; + uint8_t capturedUsed_ = 0; + uint16_t capturedSteps_ = 0; +@@ -119,3 +138,7 @@ static_assert(MacroQueue::QueueSize >= + "macro byte ring must retain at least two maximum actions"); + static_assert(MacroQueue::QueueCapacity < 0x80U, + "macro used/head arithmetic requires a seven-bit capacity"); ++ ++#undef PCCONTROLLER_PACKED ++#undef PCCONTROLLER_PACK_POP ++#undef PCCONTROLLER_PACK_PUSH +diff --git a/Project/ProtocolContract.h b/Project/ProtocolContract.h +index bd6863f8..a10cf9b7 100644 +--- a/Project/ProtocolContract.h ++++ b/Project/ProtocolContract.h +@@ -4,6 +4,8 @@ + + namespace ControllerProtocol { + ++constexpr uint8_t MaximumPayload = 48; ++ + // Opcode is the platform-neutral native request, response, and event registry. + // AVR transport and VirtualBoard/shared policy code include this one contract. + enum Opcode : uint8_t { +@@ -86,4 +88,44 @@ enum Error : uint8_t { + Unsafe = 6, + }; + ++// EventType is the byte at the beginning of an asynchronous Event payload. ++// It lives beside Opcode so the AVR, VirtualBoard, and host generators consume ++// one platform-neutral registry instead of maintaining parallel definitions. ++enum class EventType : uint8_t { ++ Key = 1, ++ Door = 2, ++ Bluetooth = 3, ++ PwmChannel = 4, ++ RfLearned = 5, ++ Macro = 6, ++ Fault = 7, ++ RfReceived = 8, ++ RfLearning = 9, ++ Relay = 10, ++ Alert = 11, ++ AppNavigation = 12, ++ Action = 13, ++}; ++ ++// InputEventSource identifies the origin of accepted action evidence. ++enum class InputEventSource : uint8_t { ++ Physical = 0, ++ Radio = 1, ++ Host = 2, ++}; ++ ++enum FeatureProfile : uint8_t { ++ FullPeripheralProfile = 0, ++ MotionMacroProfile = 1, ++ KeyDiagnosticProfile = 2, ++ CustomFeatureProfile = 3, ++}; ++ ++enum BuildFeatureFlag : uint8_t { ++ LocalMacroCapture = 1U << 0, ++ UnifiedPageIdentifiesKeys = 1U << 1, ++ ForceSilent = 1U << 2, ++ BlankEepromSilent = 1U << 3, ++}; ++ + } // namespace ControllerProtocol +diff --git a/Project/PwmController.cpp b/Project/PwmController.cpp +index 91b164a2..2745bdcc 100644 +--- a/Project/PwmController.cpp ++++ b/Project/PwmController.cpp +@@ -1,5 +1,7 @@ + #include "PwmController.h" + ++#if PCCONTROLLER_ENABLE_PCA9685 ++ + namespace { + + // Repeated transport failures mark the shared PWM expander unavailable. +@@ -289,3 +291,5 @@ uint16_t PwmController::from8Bit(uint8_t value) { + return static_cast(static_cast(value) * 16U + + value / 16U); + } ++ ++#endif // PCCONTROLLER_ENABLE_PCA9685 +diff --git a/Project/PwmController.h b/Project/PwmController.h +index 17c4812c..01600740 100644 +--- a/Project/PwmController.h ++++ b/Project/PwmController.h +@@ -4,7 +4,11 @@ + + #include "../LocalLib/BoardPins.h" + #include "../ProjectConfig.h" ++#if PCCONTROLLER_ENABLE_PCA9685 + #include "PwmExpanderDriver.h" ++#else ++class PwmExpanderDriver; ++#endif + + #ifndef PCCONTROLLER_PWM_ACTIVE_LOW + #define PCCONTROLLER_PWM_ACTIVE_LOW 0 +@@ -44,6 +48,7 @@ enum class PwmChannelRole : uint8_t { + // Logical values always use 0 = electrically inactive and 4095 = fully + // active. The optional polarity mapping keeps callers independent of register + // FULL_ON/FULL_OFF details if a later board revision inverts the stages. ++#if PCCONTROLLER_ENABLE_PCA9685 + class PwmController { + public: + explicit PwmController(PwmExpanderDriver &driver); +@@ -106,6 +111,41 @@ private: + uint8_t consecutiveWriteErrors_ = 0; + bool available_ = false; + }; ++#else ++// API-compatible zero-cost facade for profiles without a PCA9685. Callers ++// keep one controller contract while result-bearing operations truthfully fail. ++class PwmController { ++public: ++ PwmController() = default; ++ explicit PwmController(PwmExpanderDriver &) {} ++ void begin(bool, uint32_t = millis()) {} ++ void setChannel(uint8_t, uint32_t = millis()) {} ++ void adjustChannel(int8_t, uint32_t = millis()) {} ++ uint8_t channel() const { return 0; } ++ void setValue(uint16_t, uint32_t = millis()) {} ++ void adjustValue(int16_t, uint32_t = millis()) {} ++ uint16_t value() const { return 0; } ++ bool available() const { return false; } ++ uint8_t errorCount() const { return 0; } ++ bool tryAllOff() { return false; } ++ bool clearMask(uint16_t) { return false; } ++ bool stopUserOutputs() { return false; } ++ bool setLogical(uint8_t, uint16_t) { return false; } ++ uint16_t logicalValue(uint8_t) const { return 0; } ++ bool cacheValid(uint8_t) const { return false; } ++ PwmChannelRole role(uint8_t) const { return PwmChannelRole::Invalid; } ++ bool setUserLight(uint8_t, uint16_t) { return false; } ++ bool setUserPwm(uint8_t, uint16_t) { return false; } ++ bool setEnclosureIllumination(uint16_t) { return false; } ++ bool setPowerSignal(bool) { return false; } ++ bool setStatusRgb12(uint16_t, uint16_t, uint16_t) { return false; } ++ bool setStatusRgb8(uint8_t, uint8_t, uint8_t) { return false; } ++ static constexpr uint8_t PwmI2cAddress = 0x41; ++ static constexpr uint8_t CurrentSensorI2cAddress = 0x40; ++ static constexpr float PwmFrequencyHz = 1000.0F; ++ static constexpr uint8_t recommendedMode2() { return 0x04; } ++}; ++#endif + + static_assert(BoardPins::PwmAddress == PwmController::PwmI2cAddress, + "PWM driver must use I2C address 0x41"); +diff --git a/Project/StatusLedController.cpp b/Project/StatusLedController.cpp +index 39de5db8..7d7abe61 100644 +--- a/Project/StatusLedController.cpp ++++ b/Project/StatusLedController.cpp +@@ -1,5 +1,7 @@ + #include "StatusLedController.h" + ++#if PCCONTROLLER_ENABLE_PCA9685 ++ + #include + #include + +@@ -310,3 +312,6 @@ uint8_t StatusLedController::scale(uint8_t value, uint8_t level) { + (static_cast(value) * (static_cast(level) + 1U)) >> + 8); + } ++#else ++StatusLedController statusLeds; ++#endif +diff --git a/Project/StatusLedController.h b/Project/StatusLedController.h +index f4eddabb..07199f60 100644 +--- a/Project/StatusLedController.h ++++ b/Project/StatusLedController.h +@@ -2,6 +2,8 @@ + + #include + ++#include "../ProjectConfig.h" ++ + class PwmController; + + // StatusLedMode selects the persistent operational RGB presentation. +@@ -43,6 +45,7 @@ enum class StatusLedEffect : uint8_t { + }; + + // Composes base state and transient cues onto PWM RGB channels 13..15. ++#if PCCONTROLLER_ENABLE_PCA9685 + class StatusLedController { + public: + static constexpr uint8_t ProfileCount = 19; +@@ -114,6 +117,38 @@ private: + uint32_t cueEndsAt_; // millis() deadline; zero means no active cue. + StatusLedCue cue_; + }; ++#else ++class StatusLedController { ++public: ++ static constexpr uint8_t ProfileCount = 19; ++ static constexpr uint8_t ProfilePayloadBytes = 12; ++ static constexpr uint8_t ManualCondition = 0xFF; ++ void begin(PwmController &, uint8_t, uint32_t = millis(), bool = true) {} ++ void service(uint32_t = millis()) {} ++ void setMode(StatusLedMode, uint32_t = millis()) {} ++ StatusLedMode mode() const { return StatusLedMode::Off; } ++ void setBrightness(uint8_t) {} ++ uint8_t brightness() const { return 0; } ++ void setCustom(uint8_t, uint8_t, uint8_t) {} ++ bool setEffect(StatusLedEffect, uint8_t, uint8_t, uint8_t, uint8_t, ++ uint8_t, uint8_t, uint8_t, uint8_t, uint16_t, uint8_t, ++ uint32_t = millis()) { ++ return false; ++ } ++ void cancelEffect() {} ++ StatusLedEffect effect() const { return StatusLedEffect::None; } ++ uint8_t renderedRed() const { return 0; } ++ uint8_t renderedGreen() const { return 0; } ++ uint8_t renderedBlue() const { return 0; } ++ uint8_t condition() const { return 0; } ++ bool profile(uint8_t, uint8_t *) const { return false; } ++ bool setProfile(uint8_t, const uint8_t *, uint32_t = millis()) { ++ return false; ++ } ++ void setPowerSignal(bool) {} ++ void playCue(StatusLedCue, uint16_t, uint32_t = millis()) {} ++}; ++#endif + + // statusLeds is the board-wide RGB state and cue compositor. + extern StatusLedController statusLeds; +diff --git a/Project/UartProtocol.h b/Project/UartProtocol.h +index 6e140496..2c8247c5 100644 +--- a/Project/UartProtocol.h ++++ b/Project/UartProtocol.h +@@ -12,8 +12,6 @@ constexpr uint8_t Magic = 0xA5; + // magic/length/CRC shape and opcode payload semantics instead of rejecting an + // otherwise understandable peer solely because this byte differs. + constexpr uint8_t EnvelopeRevision = 1; +-constexpr uint8_t MaximumPayload = 48; +- + // Frame is a validated zero-copy view of one decoded native packet. + struct Frame { + uint8_t opcode; +diff --git a/ProjectConfig.h b/ProjectConfig.h +index 419a4975..4c336aec 100644 +--- a/ProjectConfig.h ++++ b/ProjectConfig.h +@@ -38,6 +38,23 @@ + #define PCCONTROLLER_ENABLE_I2C_LCD 0 + #endif + ++// The current motion/macro boards intentionally omit these optional I2C and ++// OneWire modules. Keeping them as independent build features lets a later ++// full-peripheral FQBN/profile restore the exact drivers without source edits, ++// while the default deployment image spends its 328P flash on input latency, ++// relay/RF safety, and timed macro capture. ++#ifndef PCCONTROLLER_ENABLE_INA219 ++#define PCCONTROLLER_ENABLE_INA219 0 ++#endif ++ ++#ifndef PCCONTROLLER_ENABLE_DS18B20 ++#define PCCONTROLLER_ENABLE_DS18B20 0 ++#endif ++ ++#ifndef PCCONTROLLER_ENABLE_PCA9685 ++#define PCCONTROLLER_ENABLE_PCA9685 0 ++#endif ++ + // The production front panel has one input/output page. In the normal build it + // is the four-key motion surface (Side A up/down, Side B up/down). A diagnostic + // image may compile the same page as a key identifier without adding a second +@@ -83,6 +100,21 @@ + #error "PCCONTROLLER_BLANK_EEPROM_SILENT must be 0 or 1" + #endif + ++#if (PCCONTROLLER_ENABLE_INA219 != 0) && \ ++ (PCCONTROLLER_ENABLE_INA219 != 1) ++#error "PCCONTROLLER_ENABLE_INA219 must be 0 or 1" ++#endif ++ ++#if (PCCONTROLLER_ENABLE_DS18B20 != 0) && \ ++ (PCCONTROLLER_ENABLE_DS18B20 != 1) ++#error "PCCONTROLLER_ENABLE_DS18B20 must be 0 or 1" ++#endif ++ ++#if (PCCONTROLLER_ENABLE_PCA9685 != 0) && \ ++ (PCCONTROLLER_ENABLE_PCA9685 != 1) ++#error "PCCONTROLLER_ENABLE_PCA9685 must be 0 or 1" ++#endif ++ + // Rich catalog/layout presentation is host-owned. Stable page IDs and the + // fixed EEPROM bytes remain, but the AVR does not carry duplicate directory, + // ordering, hierarchy, or layout-protocol implementations in release builds. +diff --git a/Tools/Controller/internal/programmer/compile.go b/Tools/Controller/internal/programmer/compile.go +index 73040e30..408c9ef8 100644 +--- a/Tools/Controller/internal/programmer/compile.go ++++ b/Tools/Controller/internal/programmer/compile.go +@@ -319,7 +319,7 @@ func firmwareTopLevelSourceFiles(root string) ([]string, error) { + + func firmwareSourceExtension(extension string) bool { + switch strings.ToLower(extension) { +- case ".ino", ".h", ".hpp", ".c", ".cpp", ".def": ++ case ".ino", ".h", ".hpp", ".c", ".cpp": + return true + default: + return false +diff --git a/Tools/Controller/internal/programmer/programmer_test.go b/Tools/Controller/internal/programmer/programmer_test.go +index 0aa20d94..23bff796 100644 +--- a/Tools/Controller/internal/programmer/programmer_test.go ++++ b/Tools/Controller/internal/programmer/programmer_test.go +@@ -157,7 +157,7 @@ func TestCompilePlanMatchesManifestAndStagesOnlyCuratedRoots(t *testing.T) { + t.Fatal(err) + } + manifest := fmt.Sprintf( +- "LocalLib/value.cpp:%X\nMenuLogic.cpp:%X\nPCController.ino:%X\nProject/Firmware/Domain.inc.h:%X\nProject/MacroActions.def:%X\nProjectConfig.h:%X\n", ++ "LocalLib/value.cpp:%X\nMenuLogic.cpp:%X\nPCController.ino:%X\nProject/Firmware/Domain.inc.h:%X\nProject/MacroActions.inc.h:%X\nProjectConfig.h:%X\n", + sha256.Sum256([]byte("int value = 1;\n")), + sha256.Sum256([]byte("void menuLogic() {}\n")), + sha256.Sum256([]byte("void setup() {}\nvoid loop() {}\n")), +@@ -187,7 +187,7 @@ func TestCompilePlanMatchesManifestAndStagesOnlyCuratedRoots(t *testing.T) { + "PCController.ino", "ProjectConfig.h", "MenuLogic.cpp", + filepath.Join("LocalLib", "value.cpp"), + filepath.Join("Project", "Firmware", "Domain.inc.h"), +- filepath.Join("Project", "MacroActions.def"), ++ filepath.Join("Project", "MacroActions.inc.h"), + } { + if _, err := os.Stat(filepath.Join(staged.SketchPath, relative)); err != nil { + t.Fatalf("missing staged source %s: %v", relative, err) +@@ -222,7 +222,7 @@ func firmwareCompileFixture(t *testing.T) string { + "MenuLogic.cpp": "void menuLogic() {}\n", + filepath.Join("LocalLib", "value.cpp"): "int value = 1;\n", + filepath.Join("Project", "Firmware", "Domain.inc.h"): "void firmwareDomain() {}\n", +- filepath.Join("Project", "MacroActions.def"): "PCCONTROLLER_MACRO_ACTION(Buzzer, 4)\n", ++ filepath.Join("Project", "MacroActions.inc.h"): "PCCONTROLLER_MACRO_ACTION(Buzzer, 4)\n", + filepath.Join(".cache", "poison.cpp"): "#error must not be staged\n", + } + for name, content := range files { +diff --git a/Tools/VirtualBoard/tests/firmware_controls_test.cpp b/Tools/VirtualBoard/tests/firmware_controls_test.cpp +index 094040fa..0405c8c3 100644 +--- a/Tools/VirtualBoard/tests/firmware_controls_test.cpp ++++ b/Tools/VirtualBoard/tests/firmware_controls_test.cpp +@@ -449,6 +449,19 @@ void testFrontPanelLeafDecreaseDispatch() { + unifiedInputIntent(MENU_INCREASE, false) == + UnifiedInputIntent::Motion, + "normal unified page no longer exposes macro/motion actions"); ++ require(unifiedMacroGesture(KeyEvent::Down, false, false) == ++ UnifiedMacroGesture::ImmediateCapture && ++ unifiedMacroGesture(KeyEvent::HoldRepeat, false, false) == ++ UnifiedMacroGesture::None && ++ unifiedMacroGesture(KeyEvent::Down, true, false) == ++ UnifiedMacroGesture::None && ++ unifiedMacroGesture(KeyEvent::Click, true, false) == ++ UnifiedMacroGesture::Replay && ++ unifiedMacroGesture(KeyEvent::HoldStart, true, false) == ++ UnifiedMacroGesture::ReplaceCapture && ++ unifiedMacroGesture(KeyEvent::Click, true, true) == ++ UnifiedMacroGesture::SuppressClassification, ++ "unified macro key lost one-shot replay/replace classification"); + + const MotionKeyBinding expectedMotion[] = { + {0, false}, {0, true}, {1, false}, {1, true}}; +diff --git a/Tools/VirtualBoard/tests/firmware_macro_queue_test.cpp b/Tools/VirtualBoard/tests/firmware_macro_queue_test.cpp +index aadb8bd4..3cedd790 100644 +--- a/Tools/VirtualBoard/tests/firmware_macro_queue_test.cpp ++++ b/Tools/VirtualBoard/tests/firmware_macro_queue_test.cpp +@@ -30,6 +30,8 @@ void testSharedActionRegistry() { + require(!MacroAction::macroQueueableOpcode(Reset) && + !MacroAction::macroQueueableOpcode(MacroStart) && + MacroAction::playbackAllowed(StatusEffect) && ++ MacroAction::validPlaybackPayload(RelaySide, 2) && ++ !MacroAction::validPlaybackPayload(RelaySide, 1) && + !MacroAction::recordable(StatusEffect, 48) && + !MacroAction::recordable(Buzzer, 3), + "control-plane or truncated action entered the macro allow-list"); +@@ -52,12 +54,13 @@ void testBoardCaptureAndExactPlayback() { + queue.captured(), + "board capture did not retain two exact ordinary actions"); + +- require(queue.playCapture(5000), "captured macro did not enter playback"); ++ require(queue.playCapture(5000) && !queue.hostDependent(), ++ "local capture playback incorrectly depends on a live host"); + ControllerProtocol::Frame frame{}; +- arduino_mock::nowMicros = 4998; ++ arduino_mock::nowMicros = 5098; + require(!queue.dequeueDue(frame), + "rebased first captured action ran before its playback epoch"); +- arduino_mock::nowMicros = 4999; ++ arduino_mock::nowMicros = 5099; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::RelaySide && + frame.payloadLength == sizeof(side) && frame.payload[0] == 1 && +@@ -65,10 +68,10 @@ void testBoardCaptureAndExactPlayback() { + "first captured ordinary opcode/payload changed during playback"); + queue.completeStep(true); + +- arduino_mock::nowMicros = 5248; ++ arduino_mock::nowMicros = 5348; + require(!queue.dequeueDue(frame), + "second captured action ran before its exact 250 us delta"); +- arduino_mock::nowMicros = 5249; ++ arduino_mock::nowMicros = 5349; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::Buzzer && + frame.payloadLength == sizeof(buzzer), +@@ -80,12 +83,12 @@ void testBoardCaptureAndExactPlayback() { + + require(queue.playCapture(7000), + "captured macro could not replay a second time offline"); +- arduino_mock::nowMicros = 6999; ++ arduino_mock::nowMicros = 7099; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::RelaySide, + "second replay changed the first retained action"); + queue.completeStep(true); +- arduino_mock::nowMicros = 7249; ++ arduino_mock::nowMicros = 7349; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::Buzzer, + "second replay changed the exact retained timing"); +@@ -145,6 +148,80 @@ void testHostileRawStreamIsRejected() { + queue.takeSafeStopRequest(), + "hostile raw macro opcode survived firmware validation"); + } ++ ++ HardwareSerial serial; ++ ControllerProtocol::UartProtocol protocol(serial); ++ MacroQueue malformed(protocol); ++ const std::uint8_t start[] = {MacroQueue::Schema, 1, 0, 1, 0}; ++ const ControllerProtocol::Frame startFrame{ ++ ControllerProtocol::MacroStart, 1, sizeof(start), start}; ++ require(malformed.handle(startFrame), ++ "malformed-length fixture could not start"); ++ const std::uint8_t shortSide[] = { ++ 0, 0, 0, 1, 0, 0, 0, 0, 0, ControllerProtocol::RelaySide, 1, 0}; ++ const ControllerProtocol::Frame shortSideFrame{ ++ ControllerProtocol::MacroStep, 2, sizeof(shortSide), shortSide}; ++ require(malformed.handle(shortSideFrame) && !malformed.active() && ++ malformed.takeSafeStopRequest(), ++ "wrong-length RelaySide raw record survived APPEND validation"); ++} ++ ++void testLifecycleAndStreamingStateValidation() { ++ HardwareSerial serial; ++ ControllerProtocol::UartProtocol protocol(serial); ++ MacroQueue queue(protocol); ++ ++ const std::uint8_t emptyStart[] = {MacroQueue::Schema, 1, 0, 0, 0}; ++ const ControllerProtocol::Frame emptyStartFrame{ ++ ControllerProtocol::MacroStart, 1, sizeof(emptyStart), emptyStart}; ++ require(queue.handle(emptyStartFrame) && !queue.active(), ++ "zero-step host macro entered an infinite Playing path"); ++ ++ require(queue.beginCapture(2, 10), ++ "recording-state APPEND fixture could not start"); ++ const std::uint8_t appendDuringCapture[] = { ++ 0, 0, 0, 1, 0, 0, 0, 0, 0, ControllerProtocol::RelayAllOff, 0}; ++ const ControllerProtocol::Frame appendDuringCaptureFrame{ ++ ControllerProtocol::MacroStep, 2, sizeof(appendDuringCapture), ++ appendDuringCapture}; ++ require(queue.handle(appendDuringCaptureFrame) && queue.recording() && ++ queue.retainedSteps() == 0, ++ "host APPEND mutated the board-owned Recording ring"); ++ const std::uint8_t side[] = {0, 1}; ++ require(queue.captureAction(ControllerProtocol::RelaySide, side, ++ sizeof(side), 20) && ++ queue.finishCapture(), ++ "rejected host fragment damaged the local capture lifecycle"); ++ ++ MacroQueue empty(protocol); ++ require(empty.beginCapture(3, 0) && empty.finishCapture() && ++ empty.captured() && !empty.active() && ++ empty.beginCapture(4, 1), ++ "empty capture did not publish a replaceable terminal state"); ++ require(!empty.captureAction(ControllerProtocol::RelayAllOff, nullptr, 0, ++ 0x80000001UL) && ++ empty.captured(), ++ "capture accepted a due time outside the signed scheduler window"); ++ ++ for (const bool badCount : {false, true}) { ++ MacroQueue streamed(protocol); ++ const std::uint8_t start[] = {MacroQueue::Schema, 5, 0, 2, 0}; ++ const ControllerProtocol::Frame startFrame{ ++ ControllerProtocol::MacroStart, 3, sizeof(start), start}; ++ require(streamed.handle(startFrame), "stream validation could not start"); ++ std::uint8_t append[] = { ++ 0, 0, 0, static_cast(badCount ? 2 : 1), 0, ++ 0, 0, 0, 0, ControllerProtocol::RelayAllOff, 0}; ++ if (!badCount) { ++ append[8] = 0x80; // due=0x80000000, outside signed window ++ } ++ const ControllerProtocol::Frame appendFrame{ ++ ControllerProtocol::MacroStep, 4, sizeof(append), append}; ++ require(streamed.handle(appendFrame) && !streamed.active() && ++ streamed.takeSafeStopRequest(), ++ badCount ? "forged complete-step count survived APPEND validation" ++ : "out-of-window raw due time survived APPEND validation"); ++ } + } + + void testFrontPanelMotionCapturesSemanticSideActions() { +@@ -166,13 +243,13 @@ void testFrontPanelMotionCapturesSemanticSideActions() { + queue.finishCapture() && queue.playCapture(1000), + "front-panel A up/down did not enter semantic capture"); + ControllerProtocol::Frame frame{}; +- arduino_mock::nowMicros = 999; ++ arduino_mock::nowMicros = 1009; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::RelaySide && + frame.payload[0] == 0 && frame.payload[1] == 1, + "front-panel A-up replay depends on menu/key mode"); + queue.completeStep(true); +- arduino_mock::nowMicros = 1049; ++ arduino_mock::nowMicros = 1059; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::RelaySide && + frame.payload[0] == 0 && frame.payload[1] == 2, +@@ -192,21 +269,66 @@ void testCaptureClockRollover() { + queue.finishCapture() && queue.playCapture(1000), + "rollover capture fixture could not start"); + ControllerProtocol::Frame frame{}; +- arduino_mock::nowMicros = 999; ++ arduino_mock::nowMicros = 1007; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::RelayAllOff, + "first rollover action was not rebased to playback start"); + queue.completeStep(true); +- arduino_mock::nowMicros = 1038; ++ arduino_mock::nowMicros = 1046; + require(!queue.dequeueDue(frame), + "rollover delta fired one microsecond early"); +- arduino_mock::nowMicros = 1039; ++ arduino_mock::nowMicros = 1047; + require(queue.dequeueDue(frame) && + frame.opcode == ControllerProtocol::PwmAllOff, + "uint32 rollover changed the exact 40 us action delta"); + queue.completeStep(true); + } + ++void testCapturePersistenceAcknowledgementIsIdentityGuarded() { ++ HardwareSerial serial; ++ ControllerProtocol::UartProtocol protocol(serial); ++ MacroQueue queue(protocol); ++ require(queue.beginCapture(7, 100), ++ "capture ACK fixture could not start"); ++ const std::uint8_t side[] = {1, 1}; ++ require(queue.captureAction(ControllerProtocol::RelaySide, side, ++ sizeof(side), 120) && ++ queue.finishCapture(), ++ "capture ACK fixture could not seal"); ++ ++ std::uint8_t clear[] = {4, 7, 99, 0, 0, 0}; ++ ControllerProtocol::Frame clearFrame{ ++ ControllerProtocol::MacroStep, 8, sizeof(clear), clear}; ++ require(queue.handle(clearFrame) && queue.captured(), ++ "stale capture ACK cleared a retained recording"); ++ clear[2] = 100; ++ require(queue.handle(clearFrame) && queue.captured() && ++ queue.playCapture(1000), ++ "matching export ACK destroyed board-local replay data"); ++ ControllerProtocol::Frame replay{}; ++ arduino_mock::nowMicros = 1019; ++ require(queue.dequeueDue(replay), ++ "export-acknowledged capture could not replay"); ++ queue.completeStep(true); ++ require(queue.captured(), ++ "export state was lost after local replay"); ++ ++ clear[0] = 5; ++ require(queue.handle(clearFrame) && !queue.captured() && ++ queue.takeSafeStopRequest(), ++ "explicit identity-guarded clear retained capture/outputs"); ++ ++ require(queue.beginCapture(7, 200) && ++ queue.captureAction(ControllerProtocol::RelaySide, side, ++ sizeof(side), 220) && ++ queue.finishCapture(), ++ "ID-reuse capture fixture could not seal"); ++ clear[0] = 4; ++ clear[2] = 100; ++ require(queue.handle(clearFrame) && queue.captured(), ++ "old ID/start ACK deleted a newer reused-ID capture"); ++} ++ + } // namespace + + int main() { +@@ -215,7 +337,9 @@ int main() { + testBoardCaptureAndExactPlayback(); + testCaptureNeverOverwrites(); + testCaptureClockRollover(); ++ testCapturePersistenceAcknowledgementIsIdentityGuarded(); + testHostileRawStreamIsRejected(); ++ testLifecycleAndStreamingStateValidation(); + testFrontPanelMotionCapturesSemanticSideActions(); + std::cout << "firmware_macro_queue_tests: all checks passed\n"; + return 0; diff --git a/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-interrupted-motion-macro-integra.patch b/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-interrupted-motion-macro-integra.patch new file mode 100644 index 000000000..78b89d264 --- /dev/null +++ b/docs/recovery/motion-macro-checkpoints-20260824/0001-checkpoint-preserve-interrupted-motion-macro-integra.patch @@ -0,0 +1,4894 @@ +From a2e62f38cfe21de9c0c86ff564dfc824d4699ec9 Mon Sep 17 00:00:00 2001 +From: Atomic Deploy +Date: Mon, 24 Aug 2026 21:19:59 +0330 +Subject: [PATCH] checkpoint: preserve interrupted motion macro integration + +This WIP snapshot intentionally preserves unresolved conflict markers for recovery. It is not merge-ready. +--- + Tools/Controller/README.md | 15 +- + Tools/Controller/cmd/controller/board_cli.go | 5 +- + .../cmd/controller/eeprom_cli_test.go | 14 +- + .../cmd/controller/programming_cli.go | 34 +- + Tools/Controller/internal/appconfig/config.go | 19 + + Tools/Controller/internal/control/commands.go | 62 +- + .../internal/control/lcd_i2c_test.go | 31 + + Tools/Controller/internal/control/macros.go | 799 +++++++++++++++++- + .../internal/control/macros_test.go | 528 ++++++++++++ + .../internal/control/programming_lifecycle.go | 139 ++- + .../control/programming_lifecycle_test.go | 104 +-- + Tools/Controller/internal/control/runtime.go | 179 +++- + .../internal/control/runtime_test.go | 24 + + .../internal/ipcjson/wire_interop_test.go | 4 +- + .../Controller/internal/link/session_test.go | 9 +- + .../native/cmd/genmacrocontract/main.go | 210 +++++ + .../Controller/internal/native/frame_test.go | 18 +- + .../native/macro_contract_generated.go | 40 + + .../Controller/internal/native/macro_queue.go | 123 ++- + .../internal/native/macro_queue_test.go | 116 ++- + Tools/Controller/internal/native/responses.go | 48 +- + .../internal/programmer/compile_manifest.go | 18 + + .../programmer/compile_manifest_test.go | 2 + + .../internal/programmer/default_eeprom.go | 129 ++- + .../programmer/default_eeprom_test.go | 17 +- + .../Controller/internal/programmer/eeprom.go | 187 +++- + .../internal/programmer/eeprom_test.go | 31 +- + .../internal/programmer/eeprom_transfer.go | 26 +- + .../programmer/eeprom_transfer_test.go | 41 +- + .../internal/programmer/programmer.go | 4 +- + .../Controller/internal/programmer/restore.go | 2 +- + .../programmer/restore_preflash_test.go | 8 +- + .../internal/programmer/stack_budget.go | 21 +- + .../internal/programmer/stack_budget_test.go | 59 +- + .../internal/sessionsnapshot/snapshot_test.go | 2 +- + Tools/Controller/internal/tui/macros.go | 4 + + Tools/Controller/internal/tui/model.go | 3 +- + Tools/Controller/internal/tui/pages.go | 4 +- + .../Controller/web/src/advanced-workbench.tsx | 12 + + Tools/Controller/web/src/types.ts | 2 + + Tools/Controller/web/src/views.tsx | 8 + + docs/Front-Panel-and-Menus.md | 15 +- + docs/Host-Configuration-and-Integrations.md | 53 +- + docs/Requirements-Backlog.md | 2 +- + docs/Toolchain-and-Safe-Programming.md | 66 +- + 45 files changed, 2808 insertions(+), 429 deletions(-) + create mode 100644 Tools/Controller/internal/native/cmd/genmacrocontract/main.go + create mode 100644 Tools/Controller/internal/native/macro_contract_generated.go + +diff --git a/Tools/Controller/README.md b/Tools/Controller/README.md +index 8cf8c177..a53cb892 100644 +--- a/Tools/Controller/README.md ++++ b/Tools/Controller/README.md +@@ -484,7 +484,7 @@ silent status|on|off # legacy alias for board silent + silent board|host|both status|on|off + display segments|lcd|both [--speed 220ms] [--duration 5s] + [--repeat once|loop|interval] [--interval 30s] [--scroll] [--] [TEXT] +-macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|delete NAME_OR_ID ++macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|update NAME_OR_ID NEW_NAME [CATEGORY [COLOR]]|rename NAME_OR_ID NEW_NAME|delete NAME_OR_ID + macro record start NAME [CATEGORY [COLOR]]|record status|record save|record discard + macro play NAME_OR_ID|status|cancel [keep] + automation list|run NAME +@@ -1017,9 +1017,11 @@ remains marked `incomplete`; it is never reported as a complete backup. + + ### Offline current settings transfer + +-No unpublished alpha-build migration/version chain is retained. File-only +-commands understand the current semantic 40-value-byte settings/name record +-plus CRC-8 and report any other width/layout as unsupported: ++No unpublished alpha-build migration/version chain is retained. The production ++record is schema 1: 22 controller bytes, one name-length byte, eight name bytes, ++and CRC-8 (32 bytes total at `0x0020..0x003F`). File-only commands also recognize ++the retired 41-byte menu-layout record only to perform an explicit, one-time ++semantic conversion; they never replay its raw bytes into production firmware: + + During alpha, version builds may replace unpublished layouts and use the raw + backup plus explicit `--reinitialize-eeprom` path. Compatibility/preservation +@@ -1038,8 +1040,9 @@ bin\controller.exe program --operation write-eeprom --method urclock --port COM1 + ``` + + Export, import, and restore require a complete validated backup manifest. +-Import overlays only EEPROM addresses `0x0020..0x0048` and preserves every +-other byte from the full 1,024-byte backup. Outputs are canonical, hashed, ++Import overlays only the compiled 32-byte settings record, erases the retired ++layout tail at `0x0040..0x0048`, and preserves every other byte from the full ++1,024-byte backup. Outputs are canonical, hashed, + created without overwrite, and never written to a device by these commands. + An actual EEPROM write remains a separate explicitly confirmed operation. + +diff --git a/Tools/Controller/cmd/controller/board_cli.go b/Tools/Controller/cmd/controller/board_cli.go +index b4d782f0..7c4d0013 100644 +--- a/Tools/Controller/cmd/controller/board_cli.go ++++ b/Tools/Controller/cmd/controller/board_cli.go +@@ -401,7 +401,7 @@ func firstBootHealthChecks(ctx context.Context, runtime *control.Runtime, output + return nil, fmt.Errorf("read first-boot status: %w", err) + } + snapshot := runtime.Snapshot() +- fmt.Fprintf(output, "HELLO: build=%08X schema=%d capabilities=%08X\n", snapshot.Hello.BuildHash, snapshot.Hello.IdentitySchema, snapshot.Hello.Capabilities) ++ fmt.Fprintf(output, "HELLO: build=%08X schema=%d capabilities=%08X profile=%s(%d) build_features=%02X\n", snapshot.Hello.BuildHash, snapshot.Hello.IdentitySchema, snapshot.Hello.Capabilities, native.FeatureProfileName(snapshot.Hello.FeatureProfile), snapshot.Hello.FeatureProfile, snapshot.Hello.BuildFeatures) + fmt.Fprintf(output, "STATUS: uptime=%s reset_cause=%d reset_count=%d framing=%d crc=%d\n", status.ReadableUptime(), status.ResetCause, status.ResetCount, status.FramingErrors, status.CRCErrors) + + settingsPersisted := false +@@ -461,6 +461,9 @@ func firstBootHealthChecks(ctx context.Context, runtime *control.Runtime, output + return map[string]any{ + "hello_build_hash": fmt.Sprintf("%08X", snapshot.Hello.BuildHash), + "identity_schema": snapshot.Hello.IdentitySchema, ++ "feature_profile": native.FeatureProfileName(snapshot.Hello.FeatureProfile), ++ "feature_profile_id": snapshot.Hello.FeatureProfile, ++ "build_features": fmt.Sprintf("%02X", snapshot.Hello.BuildFeatures), + "settings_persisted": settingsPersisted, + "i2c_addresses": addressText, + "temperature_sensors": len(temperatures), +diff --git a/Tools/Controller/cmd/controller/eeprom_cli_test.go b/Tools/Controller/cmd/controller/eeprom_cli_test.go +index 1223f4de..b12334fb 100644 +--- a/Tools/Controller/cmd/controller/eeprom_cli_test.go ++++ b/Tools/Controller/cmd/controller/eeprom_cli_test.go +@@ -30,7 +30,7 @@ func TestEEPROMInspectCLIIsConfigAndDeviceIndependent(t *testing.T) { + } + for _, want := range []string{ + `"supported": true`, `"valid": true`, +- `"format": "current/unversioned-40+crc8"`, `"visible_menu_mask": 16383`, ++ `"format": "schema1/core22+name9+crc8"`, `"schema": 1`, + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("inspection output missing %q:\n%s", want, stdout.String()) +@@ -89,19 +89,17 @@ func TestEEPROMFactoryDefaultsCLIWritesCompleteImageWithoutBoard(t *testing.T) { + } + + func currentSettingsFixture() []byte { +- values := make([]byte, 40) ++ values := make([]byte, 31) + values[1] = 1 + values[2] = 180 + values[4] = 5 + values[5] = 128 + values[6] = 0x06 + binary.LittleEndian.PutUint16(values[7:9], 500) +- binary.LittleEndian.PutUint16(values[19:21], 0x3FFF) +- copy(values[21:28], []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC}) +- values[28] = 2 +- values[29] = 0 +- values[30] = 1 +- values[31] = 0 ++ values[19] = 2 ++ values[20] = 0 ++ values[21] = 1 ++ values[22] = 0 + return values + } + +diff --git a/Tools/Controller/cmd/controller/programming_cli.go b/Tools/Controller/cmd/controller/programming_cli.go +index 588d9d48..556f0a62 100644 +--- a/Tools/Controller/cmd/controller/programming_cli.go ++++ b/Tools/Controller/cmd/controller/programming_cli.go +@@ -98,7 +98,7 @@ func runProgramWithConfig( + reinitializeEEPROM := flags.Bool( + "reinitialize-eeprom", + false, +- "development only: retain raw EEPROM backup but discard incompatible semantic settings", ++ "development only: retain raw EEPROM backup and migrate compatible semantic settings into the compiled layout", + ) + confirmEEPROM := flags.Bool( + "confirm-eeprom-write", +@@ -816,7 +816,7 @@ func executeGuardedCLIFlash( + if application != nil && programmingSession != nil && reinitializeEEPROM { + afterBackup = func( + backupContext context.Context, +- _ programmer.AutomaticPreflashResult, ++ backupResult programmer.AutomaticPreflashResult, + writer io.Writer, + ) error { + application.ResumeAuto() +@@ -842,6 +842,13 @@ func executeGuardedCLIFlash( + if closeErr != nil { + return fmt.Errorf("release application UART after arming programming latch: %w", closeErr) + } ++ if err := programmer.ProgramMigratedProgrammingEEPROM( ++ context.WithoutCancel(backupContext), backupResult.BackupManifest, ++ paths, options, programmer.Execute, writer, ++ ); err != nil { ++ return fmt.Errorf("preseed migrated Silent/Prog EEPROM before flash: %w", err) ++ } ++ fmt.Fprintln(writer, "semantic EEPROM migration programmed and verified before firmware write") + return nil + } + } +@@ -874,16 +881,6 @@ func executeGuardedCLIFlash( + fmt.Fprintln(output, "guarded firmware flash completed") + } + verifiedProgram := flashErr == nil && result.Flashed +- if verifiedProgram && reinitializeEEPROM { +- if factoryErr := programFactoryEEPROM( +- context.WithoutCancel(ctx), paths, options, output, +- ); factoryErr != nil { +- flashErr = errors.Join(flashErr, factoryErr) +- verifiedProgram = false +- } else { +- fmt.Fprintln(output, "host-owned Silent/Prog factory EEPROM programmed and independently read back") +- } +- } + if programmingSession != nil { + if markerErr := control.MarkProgrammingSessionComplete( + programmingSession, verifiedProgram, +@@ -947,17 +944,6 @@ func executeGuardedCLIFlash( + return errors.Join(flashErr, reconnectErr, restoreErr) + } + +-func programFactoryEEPROM( +- ctx context.Context, +- paths programmer.HostDataPaths, +- base programmer.Options, +- output io.Writer, +-) error { +- return programmer.ProgramLatchedFactoryEEPROM( +- ctx, paths, base, programmer.Execute, output, +- ) +-} +- + func readApplicationIdentityBeforeProgramming( + port string, + connection appconfig.Connection, +@@ -1604,7 +1590,7 @@ func runWS(args []string, stdout, stderr io.Writer, store *appconfig.Store) erro + avrdude := flags.String("avrdude", config.Programming.Avrdude, "avrdude executable") + avrdudeConf := flags.String("avrdude-conf", config.Programming.AvrdudeConf, "avrdude.conf path") + allowIncomplete := flags.Bool("allow-incomplete-backup", false, "explicitly allow flashing without a complete verified backup") +- reinitializeEEPROM := flags.Bool("reinitialize-eeprom", false, "development only: retain raw EEPROM backup but discard incompatible semantic settings") ++ reinitializeEEPROM := flags.Bool("reinitialize-eeprom", false, "development only: retain raw EEPROM backup and migrate compatible semantic settings into the compiled layout") + reconnect := flags.Duration("reconnect", 2*time.Second, "reconnect delay") + maxSize := flags.Int64("max-size", wsrelay.DefaultMaxSize, "maximum firmware bytes") + if err := flags.Parse(args[1:]); err != nil { +diff --git a/Tools/Controller/internal/appconfig/config.go b/Tools/Controller/internal/appconfig/config.go +index daaa3062..39cabe2c 100644 +--- a/Tools/Controller/internal/appconfig/config.go ++++ b/Tools/Controller/internal/appconfig/config.go +@@ -231,6 +231,16 @@ type Macro struct { + LCDMessage string `json:"lcd_message,omitempty"` + TimingToleranceUS uint32 `json:"timing_tolerance_us,omitempty"` + KeepOutputsOnCancel bool `json:"keep_outputs_on_cancel,omitempty"` ++<<<<<<< HEAD ++======= ++ RecordingSource string `json:"recording_source,omitempty"` ++ CaptureDroppedSteps uint16 `json:"capture_dropped_steps,omitempty"` ++ CaptureMissingSteps uint16 `json:"capture_missing_steps,omitempty"` ++ CaptureImportKey string `json:"capture_import_key,omitempty"` ++ CaptureBoard string `json:"capture_board,omitempty"` ++ CaptureID byte `json:"capture_id,omitempty"` ++ CaptureStartedAtUS uint32 `json:"capture_started_at_us,omitempty"` ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + Steps []MacroStep `json:"steps"` + } + +@@ -692,6 +702,15 @@ func (value Config) Validate() error { + if macro.TimingToleranceUS > 1_000_000 { + return fmt.Errorf("macros[%d].timing_tolerance_us must be 0..1000000", index) + } ++ if macro.CaptureImportKey != "" { ++ decoded, err := hex.DecodeString(macro.CaptureImportKey) ++ if err != nil || len(decoded) != sha256.Size { ++ return fmt.Errorf("macros[%d].capture_import_key must be a 64-character SHA-256 hex digest", index) ++ } ++ } ++ if len(macro.CaptureBoard) > 256 || !printableASCII(macro.CaptureBoard) { ++ return fmt.Errorf("macros[%d].capture_board must be at most 256 printable ASCII bytes", index) ++ } + if len(macro.Label) > 4 || !printableASCII(macro.Label) { + return fmt.Errorf("macros[%d].label must be at most four printable ASCII bytes", index) + } +diff --git a/Tools/Controller/internal/control/commands.go b/Tools/Controller/internal/control/commands.go +index 0f467f86..22a55612 100644 +--- a/Tools/Controller/internal/control/commands.go ++++ b/Tools/Controller/internal/control/commands.go +@@ -812,7 +812,7 @@ func NewCommandEngine(runtime *Runtime, options CommandOptions) *shell.Engine { + }, + }) + mustRegister(shell.Command{ +- Name: "macro", Usage: "macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|delete NAME_OR_ID|record start NAME [CATEGORY [COLOR]]|record status|record save|record discard|play NAME_OR_ID|status|cancel [keep]", ++ Name: "macro", Usage: "macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|update NAME_OR_ID NEW_NAME [CATEGORY [COLOR]]|rename NAME_OR_ID NEW_NAME|delete NAME_OR_ID|record start NAME [CATEGORY [COLOR]]|record status|record save|record discard|play NAME_OR_ID|status|cancel [keep]", + Summary: "manage and play MCU-timed multi-peripheral macros", + Run: func(ctx context.Context, args []string) (string, error) { + return macroCommand(ctx, macroRunner, args) +@@ -3990,7 +3990,7 @@ func safeFlashCommand( + if serialWasOpen && programmingSession != nil && reinitializeEEPROM { + afterBackup = func( + backupContext context.Context, +- _ programmer.AutomaticPreflashResult, ++ backupResult programmer.AutomaticPreflashResult, + writer io.Writer, + ) error { + reconnectContext, reconnectCancel := context.WithTimeout( +@@ -4017,6 +4017,13 @@ func safeFlashCommand( + if closeErr != nil { + return fmt.Errorf("release application UART after arming programming latch: %w", closeErr) + } ++ if err := programmer.ProgramMigratedProgrammingEEPROM( ++ context.WithoutCancel(backupContext), backupResult.BackupManifest, ++ dataPaths, writeOptions, programmer.EEPROMProgramOperation(execute), writer, ++ ); err != nil { ++ return fmt.Errorf("preseed migrated Silent/Prog EEPROM before flash: %w", err) ++ } ++ fmt.Fprintln(writer, "semantic EEPROM migration programmed and verified before firmware write") + return nil + } + } +@@ -4049,19 +4056,6 @@ func safeFlashCommand( + fmt.Fprintln(&output, "guarded firmware flash completed") + } + verifiedProgram := flashErr == nil && result.Flashed +- if verifiedProgram && reinitializeEEPROM { +- factoryErr := programmer.ProgramLatchedFactoryEEPROM( +- context.WithoutCancel(ctx), dataPaths, writeOptions, +- programmer.EEPROMProgramOperation(execute), &output, +- ) +- if factoryErr != nil { +- flashErr = errors.Join(flashErr, factoryErr) +- verifiedProgram = false +- } else { +- fmt.Fprintln(&output, +- "host-owned Silent/Prog factory EEPROM programmed and independently read back") +- } +- } + if programmingSession != nil { + if markerErr := MarkProgrammingSessionComplete( + programmingSession, verifiedProgram, +@@ -4385,11 +4379,14 @@ func formatHello(hello native.Hello) string { + stamp = "unknown" + } + return fmt.Sprintf( +- "%s build=%08X timestamp=%s packed=0x%08X", ++ "%s build=%08X timestamp=%s packed=0x%08X profile=%s(%d) build_features=0x%02X", + base, + hello.BuildHash, + stamp, + hello.BuildTimestamp, ++ native.FeatureProfileName(hello.FeatureProfile), ++ hello.FeatureProfile, ++ hello.BuildFeatures, + ) + } + return base + " build identity unavailable" +@@ -4427,7 +4424,7 @@ func macroCommand( + runner *MacroRunner, + args []string, + ) (string, error) { +- const usage = "macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|delete NAME_OR_ID|record start NAME [CATEGORY [COLOR]]|record status|record save|record discard|play NAME_OR_ID|status|cancel [keep]" ++ const usage = "macro list|show NAME_OR_ID|create ID NAME [CATEGORY [COLOR]]|update NAME_OR_ID NEW_NAME [CATEGORY [COLOR]]|rename NAME_OR_ID NEW_NAME|delete NAME_OR_ID|record start NAME [CATEGORY [COLOR]]|record status|record save|record discard|play NAME_OR_ID|status|cancel [keep]" + if len(args) < 1 { + return "", fmt.Errorf("usage: %s", usage) + } +@@ -4477,12 +4474,16 @@ func macroCommand( + macro.Label, len(macro.Steps), time.Duration(compiled.durationUS)*time.Microsecond, + len(compiled.stream), macro.TimingToleranceUS, macro.KeepOutputsOnCancel, + )} ++ previousDue := uint32(0) + for index, step := range macro.Steps { + due, _ := macroStepDueUS(step) ++ delta := due - previousDue + lines = append(lines, fmt.Sprintf( +- "%3d +%-12s %-12s target=%d value=%d", +- index+1, time.Duration(due)*time.Microsecond, step.Kind, step.Target, step.Value, ++ "%3d at_us=%-10d delta_us=%-10d (+%-12s) %-12s target=%d value=%d", ++ index+1, due, delta, time.Duration(due)*time.Microsecond, ++ step.Kind, step.Target, step.Value, + )) ++ previousDue = due + } + return strings.Join(lines, "\n"), nil + case "create": +@@ -4505,6 +4506,25 @@ func macroCommand( + return "", err + } + return fmt.Sprintf("macro %d/%s draft created; add steps in the watched host config or record a new macro", macro.ID, macro.Name), nil ++<<<<<<< HEAD ++======= ++ case "update", "rename": ++ if len(args) < 3 || len(args) > 5 { ++ return "", fmt.Errorf("usage: macro update NAME_OR_ID NEW_NAME [CATEGORY [COLOR]]") ++ } ++ var category, color *string ++ if len(args) >= 4 { ++ category = &args[3] ++ } ++ if len(args) == 5 { ++ color = &args[4] ++ } ++ macro, err := runner.UpdateMetadata(args[1], args[2], category, color) ++ if err != nil { ++ return "", err ++ } ++ return fmt.Sprintf("macro %d renamed to %q category=%q color=%q", macro.ID, macro.Name, macro.Category, macro.Color), nil ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + case "delete", "remove": + if len(args) != 2 { + return "", fmt.Errorf("usage: macro delete NAME_OR_ID") +@@ -4542,7 +4562,11 @@ func macroCommand( + if !state.Active && state.Name == "" { + return "no macro has been recorded in this session", nil + } ++<<<<<<< HEAD + return fmt.Sprintf("macro recording active=%t id=%d name=%q category=%q color=%q steps=%d started=%s error=%q", state.Active, state.ID, state.Name, state.Category, state.Color, state.Steps, state.StartedAt.Format(time.RFC3339), state.LastError), nil ++======= ++ return fmt.Sprintf("macro recording active=%t id=%d name=%q category=%q color=%q steps=%d host=%d panel=%d rf=%d last_at_us=%d last_delta_us=%d last_opcode=0x%02X last_source=%d started=%s error=%q", state.Active, state.ID, state.Name, state.Category, state.Color, state.Steps, state.HostSteps, state.PanelSteps, state.RFSteps, state.LastAtUS, state.LastDeltaUS, state.LastOpcode, state.LastSource, state.StartedAt.Format(time.RFC3339), state.LastError), nil ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + case "save", "stop": + if len(args) != 2 { + return "", fmt.Errorf("usage: macro record save") +diff --git a/Tools/Controller/internal/control/lcd_i2c_test.go b/Tools/Controller/internal/control/lcd_i2c_test.go +index 2116dc58..449a1c5a 100644 +--- a/Tools/Controller/internal/control/lcd_i2c_test.go ++++ b/Tools/Controller/internal/control/lcd_i2c_test.go +@@ -3,6 +3,7 @@ package control + import ( + "bytes" + "context" ++ "errors" + "testing" + "time" + +@@ -63,6 +64,36 @@ func TestHostOwnedLCDDiscoversInitializesAndCaches(t *testing.T) { + } + } + ++func TestLCDWriteSequenceAbortsAfterFirstI2CFailure(t *testing.T) { ++ for _, test := range []struct { ++ name string ++ result native.I2CTransferResult ++ err error ++ }{ ++ {name: "transport", err: errors.New("UART timeout")}, ++ {name: "device-status", result: native.I2CTransferResult{Status: 2}}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ calls := 0 ++ lcd := newHostOwnedLCD(func( ++ _ context.Context, _ byte, _ byte, _ []byte, _ byte, ++ ) (native.I2CTransferResult, error) { ++ calls++ ++ return test.result, test.err ++ }) ++ lcd.address = 0x27 ++ // More than one 15-byte UART/I2C chunk proves no later chunk is sent. ++ sequence := make([]byte, 30) ++ if err := lcd.writeSequence(context.Background(), sequence); err == nil { ++ t.Fatal("failed I2C transfer was accepted") ++ } ++ if calls != 1 { ++ t.Fatalf("LCD continued after first failure: calls=%d", calls) ++ } ++ }) ++ } ++} ++ + func TestLCDNibbleUsesCommonPCF8574MappingAndSafePulse(t *testing.T) { + command := appendLCDNibble(nil, 0xA0, false) + wantCommand := []byte{0xA8, 0xAC, 0xA8} +diff --git a/Tools/Controller/internal/control/macros.go b/Tools/Controller/internal/control/macros.go +index d437696a..32dc14a8 100644 +--- a/Tools/Controller/internal/control/macros.go ++++ b/Tools/Controller/internal/control/macros.go +@@ -2,6 +2,7 @@ package control + + import ( + "context" ++ "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" +@@ -26,6 +27,7 @@ const ( + // bridge clients. Timing deltas are calculated solely from MCU timestamps. + type MacroState struct { + Running bool `json:"running"` ++ Generation uint64 `json:"connection_generation,omitempty"` + ID byte `json:"id"` + Name string `json:"name"` + Category string `json:"category,omitempty"` +@@ -65,6 +67,19 @@ type compiledMacro struct { + durationUS uint32 + } + ++<<<<<<< HEAD ++======= ++// boardCaptureToken binds an ephemeral MCU capture ID to one authenticated ++// connection and one board-clock epoch. IDs are intentionally reusable after ++// reboot; the full token is not. ++type boardCaptureToken struct { ++ Generation uint64 ++ ID byte ++ StartedAtUS uint32 ++ Board string ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + type MacroRunner struct { + runtime *Runtime + library func() []appconfig.Macro +@@ -80,17 +95,37 @@ type MacroRunner struct { + presentOnce sync.Once + present chan MacroState + ++<<<<<<< HEAD + recordMu sync.RWMutex + recording MacroRecordingState + recordMacro appconfig.Macro + recordBaseUS uint32 + recordHasBase bool + recordRelease func() ++======= ++ recordMu sync.RWMutex ++ recording MacroRecordingState ++ recordMacro appconfig.Macro ++ recordBaseUS uint32 ++ recordHasBase bool ++ recordBytes uint32 ++ recordRelease func() ++ recordSealed bool ++ recordCapture boardCaptureToken ++ boardCaptureMu sync.Mutex ++ boardCaptureGeneration uint64 ++ boardCaptureFinalizing map[boardCaptureToken]struct{} ++ boardCaptureFinished map[boardCaptureToken]struct{} ++ fetchCapture func(boardCaptureToken) ([]byte, error) ++ queryCaptureStatus func(uint64) (native.MacroStatus, error) ++ ackCapture func(boardCaptureToken) error ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + // MacroRecordingState describes a HOST-owned recording session. Each captured + // offset comes from the acknowledgement timestamp generated by the MCU. + type MacroRecordingState struct { ++<<<<<<< HEAD + Active bool `json:"active"` + ID byte `json:"id"` + Name string `json:"name"` +@@ -99,6 +134,26 @@ type MacroRecordingState struct { + Steps int `json:"steps"` + StartedAt time.Time `json:"started_at,omitempty"` + LastError string `json:"last_error,omitempty"` ++======= ++ Active bool `json:"active"` ++ ID byte `json:"id"` ++ Name string `json:"name"` ++ Category string `json:"category,omitempty"` ++ Color string `json:"color,omitempty"` ++ Steps int `json:"steps"` ++ HostSteps int `json:"host_steps"` ++ PanelSteps int `json:"panel_steps"` ++ RFSteps int `json:"rf_steps"` ++ LastAtUS uint32 `json:"last_at_us"` ++ LastDeltaUS uint32 `json:"last_delta_us"` ++ LastOpcode byte `json:"last_opcode"` ++ LastSource byte `json:"last_source"` ++ BoardOwned bool `json:"board_owned,omitempty"` ++ BoardID byte `json:"board_id,omitempty"` ++ DroppedSteps uint16 `json:"dropped_steps,omitempty"` ++ StartedAt time.Time `json:"started_at,omitempty"` ++ LastError string `json:"last_error,omitempty"` ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + func NewMacroRunner( +@@ -118,6 +173,17 @@ func NewMacroRunner( + runtime: runtime, library: library, hostConfig: hostConfig, + updateHostConfig: updater, + } ++<<<<<<< HEAD ++======= ++ runner.fetchCapture = runner.fetchBoardCapture ++ runner.queryCaptureStatus = runner.queryBoardMacroStatus ++ runner.ackCapture = runner.ackBoardCaptureExport ++ if runtime != nil { ++ runtime.ObserveMacroStatuses(runner.handleBoardMacroStatus) ++ runtime.ObserveConnectionReady(runner.recoverBoardCaptureOnConnect) ++ } ++ return runner ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + func (runner *MacroRunner) List() []appconfig.Macro { +@@ -165,6 +231,67 @@ func (runner *MacroRunner) CreateDraft(id byte, name, category, color string) (a + return macro, err + } + ++<<<<<<< HEAD ++======= ++// UpdateMetadata keeps names/categories/colors host-owned, including for a ++// provisional `Board capture N` recovered from the MCU ring. ++func (runner *MacroRunner) UpdateMetadata( ++ reference, name string, ++ category, color *string, ++) (appconfig.Macro, error) { ++ if runner.updateHostConfig == nil { ++ return appconfig.Macro{}, errors.New("macro persistence is unavailable") ++ } ++ macro, err := runner.find(reference) ++ if err != nil { ++ return appconfig.Macro{}, err ++ } ++ name = strings.TrimSpace(name) ++ if name == "" { ++ return appconfig.Macro{}, errors.New("macro name is required") ++ } ++ var nextCategory, nextColor *string ++ if category != nil { ++ value := strings.TrimSpace(*category) ++ if value == "-" { ++ value = "" ++ } ++ nextCategory = &value ++ } ++ if color != nil { ++ value := strings.TrimSpace(*color) ++ if value == "-" { ++ value = "" ++ } ++ value = normalizedMacroColor(value) ++ if !validMacroColor(value) { ++ return appconfig.Macro{}, fmt.Errorf("macro color %q is not red, blue, violet, green, white, or empty", value) ++ } ++ nextColor = &value ++ } ++ err = runner.updateHostConfig(func(config *appconfig.Config) error { ++ for index, existing := range config.Macros { ++ if existing.ID != macro.ID && strings.EqualFold(existing.Name, name) { ++ return fmt.Errorf("macro name %q already exists", name) ++ } ++ if existing.ID == macro.ID { ++ config.Macros[index].Name = name ++ if nextCategory != nil { ++ config.Macros[index].Category = *nextCategory ++ } ++ if nextColor != nil { ++ config.Macros[index].Color = *nextColor ++ } ++ macro = config.Macros[index] ++ return nil ++ } ++ } ++ return fmt.Errorf("macro %q disappeared before it could be updated", reference) ++ }) ++ return macro, err ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func (runner *MacroRunner) Delete(reference string) error { + if runner.updateHostConfig == nil { + return errors.New("macro persistence is unavailable") +@@ -280,8 +407,17 @@ func (runner *MacroRunner) StopRecording(save bool) (appconfig.Macro, error) { + return macro, nil + } + ++<<<<<<< HEAD + func (runner *MacroRunner) captureCommand(evidence CommandEvidence) { + if !evidence.Timed || !macroQueueableOpcode(evidence.Opcode) { ++======= ++func (runner *MacroRunner) captureAction(evidence ActionEvidence) { ++ // Successful host commands are echoed as board Action events for remote ++ // observers. Their synchronous ACK already supplied the authoritative MCU ++ // timestamp, so the echo must not become a duplicate recorded step. ++ if (evidence.BoardOrigin && evidence.Source == native.InputSourceHost) || ++ !evidence.Timed || !native.MacroPlaybackAllowed(evidence.Opcode) { ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + return + } + step, ok := recordedMacroStep(evidence) +@@ -290,7 +426,18 @@ func (runner *MacroRunner) captureCommand(evidence CommandEvidence) { + } + runner.recordMu.Lock() + defer runner.recordMu.Unlock() ++<<<<<<< HEAD + if !runner.recording.Active { ++======= ++ if !runner.recording.Active || runner.recordSealed { ++ return ++ } ++ if runner.recording.BoardOwned && ++ evidence.Generation != runner.recordCapture.Generation { ++ return ++ } ++ if runner.recording.LastError != "" { ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + return + } + if !runner.recordHasBase { +@@ -303,8 +450,575 @@ func (runner *MacroRunner) captureCommand(evidence CommandEvidence) { + return + } + step.AtUS = delta ++<<<<<<< HEAD ++======= ++ previousAtUS := uint32(0) ++ if count := len(runner.recordMacro.Steps); count != 0 { ++ previousAtUS = runner.recordMacro.Steps[count-1].AtUS ++ } ++ opcode, payload, err := compileMacroCommand(step) ++ if err != nil { ++ runner.recording.LastError = fmt.Sprintf("record action: %v", err) ++ return ++ } ++ record, err := native.EncodeMacroRecord(delta, opcode, payload) ++ if err != nil { ++ runner.recording.LastError = fmt.Sprintf("record action: %v", err) ++ return ++ } ++ if len(runner.recordMacro.Steps) >= macroMaximumRecordedSteps || ++ int(runner.recordBytes)+len(record) > macroMaximumRecordedBytes { ++ runner.recording.LastError = fmt.Sprintf( ++ "recording reached the bounded %d-step/%d-byte stream limit", ++ macroMaximumRecordedSteps, macroMaximumRecordedBytes, ++ ) ++ return ++ } ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + runner.recordMacro.Steps = append(runner.recordMacro.Steps, step) + runner.recording.Steps = len(runner.recordMacro.Steps) ++<<<<<<< HEAD ++======= ++ runner.recording.LastAtUS = step.AtUS ++ runner.recording.LastDeltaUS = step.AtUS - previousAtUS ++ runner.recording.LastOpcode = evidence.Opcode ++ runner.recording.LastSource = evidence.Source ++ switch evidence.Source { ++ case native.InputSourcePhysical: ++ runner.recording.PanelSteps++ ++ case native.InputSourceRF: ++ runner.recording.RFSteps++ ++ default: ++ runner.recording.HostSteps++ ++ } ++} ++ ++func (runner *MacroRunner) handleBoardMacroStatus( ++ status native.MacroStatus, ++ generation uint64, ++) { ++ snapshot := runner.runtime.Snapshot() ++ if snapshot.Generation != generation { ++ return ++ } ++ runner.handleBoardMacroStatusAtGeneration( ++ status, generation, captureBoardIdentity(snapshot.Port, snapshot.Hello), ++ ) ++} ++ ++func (runner *MacroRunner) handleBoardMacroStatusAtGeneration( ++ status native.MacroStatus, ++ generation uint64, ++ board string, ++) { ++ token := boardCaptureToken{ ++ Generation: generation, ++ ID: status.ID, StartedAtUS: status.StartedAtUS, Board: board, ++ } ++ switch status.State { ++ case native.MacroRecording: ++ runner.boardCaptureMu.Lock() ++ defer runner.boardCaptureMu.Unlock() ++ runner.prepareBoardCaptureGenerationLocked(token.Generation) ++ if _, finished := runner.boardCaptureFinished[token]; finished { ++ return ++ } ++ state := runner.RecordingState() ++ if state.Active { ++ runner.recordMu.RLock() ++ sameCapture := state.BoardOwned && runner.recordCapture == token ++ runner.recordMu.RUnlock() ++ if sameCapture { ++ return ++ } ++ if state.BoardOwned { ++ // A board reboot/reconnect may legally reuse its uint8 ID. Never ++ // append the new generation to an older provisional recording. ++ if _, err := runner.StopRecording(false); err != nil { ++ runner.publishBoardCaptureFailure(token, err) ++ return ++ } ++ } else { ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recording", Lifecycle: "conflict", State: "blocked", ++ Reason: "another macro recording is already active", ++ Text: fmt.Sprintf("board capture %d could not start while %d/%s is recording", status.ID, state.ID, state.Name), ++ }) ++ return ++ } ++ } ++ name := runner.uniqueBoardCaptureName(status.ID) ++ if _, err := runner.startRecording(&token, name, "board", "green"); err != nil { ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recording", Lifecycle: "failed", State: "error", ++ Reason: err.Error(), Text: fmt.Sprintf("start board capture %d: %v", status.ID, err), ++ }) ++ } ++ case native.MacroCaptured, native.MacroExported: ++ runner.beginBoardCaptureFinalization(token, status) ++ case native.MacroIdle, native.MacroCancelled, native.MacroFailed: ++ runner.finishBoardRecordingLifecycle(token, status.State) ++ } ++} ++ ++func (runner *MacroRunner) recoverBoardCaptureOnConnect( ++ generation uint64, ++ port ports.Info, ++ hello native.Hello, ++) { ++ if hello.Capabilities&native.CapabilityTimedMacroQueue == 0 { ++ return ++ } ++ status, err := runner.queryCaptureStatus(generation) ++ if err != nil { ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recovery", Lifecycle: "unavailable", State: "idle", ++ Reason: err.Error(), Text: "board macro status could not be queried after connect: " + err.Error(), ++ }) ++ return ++ } ++ if status.State == native.MacroRecording || status.State == native.MacroCaptured || ++ status.State == native.MacroExported { ++ runner.handleBoardMacroStatusAtGeneration( ++ status, generation, captureBoardIdentity(port, hello), ++ ) ++ } ++} ++ ++func (runner *MacroRunner) queryBoardMacroStatus(generation uint64) (native.MacroStatus, error) { ++ ctx, cancel := context.WithTimeout(context.Background(), macroRequestTimeout) ++ defer cancel() ++ frame, err := runner.runtime.requestAtGeneration( ++ ctx, generation, native.OpMacroStep, native.MacroQueueQueryPayload(), ++ native.OpMacroStatus, ++ ) ++ if err != nil { ++ return native.MacroStatus{}, err ++ } ++ return native.ParseMacroStatus(frame.Payload) ++} ++ ++func (runner *MacroRunner) prepareBoardCaptureGenerationLocked(generation uint64) { ++ if runner.boardCaptureGeneration == generation && ++ runner.boardCaptureFinalizing != nil && runner.boardCaptureFinished != nil { ++ return ++ } ++ runner.boardCaptureGeneration = generation ++ runner.boardCaptureFinalizing = make(map[boardCaptureToken]struct{}) ++ runner.boardCaptureFinished = make(map[boardCaptureToken]struct{}) ++} ++ ++func captureBoardIdentity(port ports.Info, hello native.Hello) string { ++ transport := strings.TrimSpace(port.SerialNumber) ++ if transport == "" { ++ transport = strings.TrimSpace(port.InstanceID) ++ } ++ if transport == "" { ++ transport = strings.TrimSpace(port.Name) ++ } ++ if transport == "" { ++ transport = "unknown" ++ } ++ if len(transport) > 160 || !printableCaptureIdentity(transport) { ++ digest := sha256.Sum256([]byte(transport)) ++ transport = "sha256:" + hex.EncodeToString(digest[:]) ++ } ++ return fmt.Sprintf( ++ "transport=%s;vid=%s;pid=%s;kind=%d;build=%08X", ++ transport, strings.ToUpper(port.VID), strings.ToUpper(port.PID), ++ hello.BoardKind, hello.BuildHash, ++ ) ++} ++ ++func printableCaptureIdentity(value string) bool { ++ for _, character := range []byte(value) { ++ if character < 0x20 || character > 0x7E { ++ return false ++ } ++ } ++ return true ++} ++ ++func (runner *MacroRunner) finishBoardRecordingLifecycle( ++ token boardCaptureToken, ++ state byte, ++) { ++ runner.boardCaptureMu.Lock() ++ defer runner.boardCaptureMu.Unlock() ++ recording := runner.RecordingState() ++ if !recording.Active || !recording.BoardOwned { ++ return ++ } ++ runner.recordMu.RLock() ++ active := runner.recordCapture ++ runner.recordMu.RUnlock() ++ if active.Generation != token.Generation || active.ID != token.ID || ++ (token.StartedAtUS != 0 && active.StartedAtUS != token.StartedAtUS) { ++ return ++ } ++ macro, err := runner.StopRecording(false) ++ if err != nil { ++ return ++ } ++ lifecycle := "discarded" ++ if state == native.MacroFailed { ++ lifecycle = "failed" ++ } ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recording", Lifecycle: lifecycle, State: lifecycle, ++ Text: fmt.Sprintf( ++ "board capture %d/%s ended in state %d and was %s", ++ macro.ID, macro.Name, state, lifecycle, ++ ), ++ }) ++} ++ ++// beginBoardCaptureFinalization freezes the Action subscription synchronously ++// in UART order. The final action that overflowed the strict prefix ring is ++// therefore retained when firmware emits Action-before-Captured, while later ++// traffic and duplicate lifecycle reports cannot change the sealed recording. ++func (runner *MacroRunner) beginBoardCaptureFinalization( ++ token boardCaptureToken, ++ status native.MacroStatus, ++) { ++ runner.boardCaptureMu.Lock() ++ runner.prepareBoardCaptureGenerationLocked(token.Generation) ++ if _, busy := runner.boardCaptureFinalizing[token]; busy { ++ runner.boardCaptureMu.Unlock() ++ return ++ } ++ if _, finished := runner.boardCaptureFinished[token]; finished { ++ runner.boardCaptureMu.Unlock() ++ return ++ } ++ ++ state := runner.RecordingState() ++ if !state.Active { ++ name := runner.uniqueBoardCaptureName(status.ID) ++ if _, err := runner.startRecording(&token, name, "board", "green"); err != nil { ++ runner.boardCaptureMu.Unlock() ++ runner.publishBoardCaptureFailure(token, err) ++ return ++ } ++ } else { ++ runner.recordMu.RLock() ++ matches := state.BoardOwned && runner.recordCapture == token ++ runner.recordMu.RUnlock() ++ if !matches { ++ runner.boardCaptureMu.Unlock() ++ runner.publishBoardCaptureFailure( ++ token, errors.New("captured lifecycle does not match the active recording"), ++ ) ++ return ++ } ++ } ++ ++ runner.recordMu.Lock() ++ if runner.recordRelease != nil { ++ runner.recordRelease() ++ runner.recordRelease = nil ++ } ++ runner.recordSealed = true ++ runner.recordMu.Unlock() ++ runner.boardCaptureFinalizing[token] = struct{}{} ++ runner.boardCaptureMu.Unlock() ++ ++ // Recovery uses request/response I/O and must never block the runtime's ++ // unsolicited event pump. ++ go runner.finalizeBoardCapture(token, status) ++} ++ ++func (runner *MacroRunner) uniqueBoardCaptureName(id byte) string { ++ base := fmt.Sprintf("Board capture %d", id) ++ used := make(map[string]bool) ++ for _, macro := range runner.List() { ++ used[strings.ToLower(macro.Name)] = true ++ } ++ if !used[strings.ToLower(base)] { ++ return base ++ } ++ for suffix := 2; ; suffix++ { ++ name := fmt.Sprintf("%s (%d)", base, suffix) ++ if !used[strings.ToLower(name)] { ++ return name ++ } ++ } ++} ++ ++func (runner *MacroRunner) finalizeBoardCapture( ++ token boardCaptureToken, ++ status native.MacroStatus, ++) { ++ stream, err := runner.fetchCapture(token) ++ if err != nil { ++ runner.finishBoardCapture(token, false) ++ runner.publishBoardCaptureFailure(token, err) ++ return ++ } ++ recovered, err := decodeMacroCaptureStream(stream) ++ if err != nil { ++ runner.finishBoardCapture(token, false) ++ runner.publishBoardCaptureFailure(token, err) ++ return ++ } ++ importKey := boardCaptureImportKey(token, stream) ++ ++ runner.boardCaptureMu.Lock() ++ defer runner.boardCaptureMu.Unlock() ++ if runner.runtime.Snapshot().Generation != token.Generation { ++ runner.recordMu.RLock() ++ stale := runner.recording.Active && runner.recording.BoardOwned && ++ runner.recordCapture == token ++ runner.recordMu.RUnlock() ++ if stale { ++ _, _ = runner.StopRecording(false) ++ } ++ runner.finishBoardCaptureLocked(token, false) ++ return ++ } ++ if existing, imported := runner.importedBoardCapture(importKey); imported { ++ _, _ = runner.StopRecording(false) ++ if err := runner.ackCapture(token); err != nil { ++ runner.finishBoardCaptureLocked(token, false) ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recovery", Lifecycle: "ack-pending", State: "saved", ++ Reason: err.Error(), Text: fmt.Sprintf( ++ "board capture %d is already saved as %d/%s but export acknowledgement failed: %v", ++ token.ID, existing.ID, existing.Name, err, ++ ), ++ }) ++ return ++ } ++ runner.finishBoardCaptureLocked(token, true) ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recovery", Lifecycle: "deduplicated", State: "saved", ++ Text: fmt.Sprintf( ++ "board capture %d was already saved as %d/%s; retained export acknowledged", ++ token.ID, existing.ID, existing.Name, ++ ), ++ }) ++ return ++ } ++ runner.recordMu.Lock() ++ if !runner.recording.Active || !runner.recording.BoardOwned || ++ runner.recordCapture != token || !runner.recordSealed { ++ runner.recordMu.Unlock() ++ runner.finishBoardCaptureLocked(token, false) ++ return ++ } ++ runner.recordMacro.Steps = mergeRecordedMacroSteps(runner.recordMacro.Steps, recovered) ++ runner.recordMacro.RecordingSource = "board" ++ runner.recordMacro.CaptureDroppedSteps = status.DroppedSteps ++ runner.recordMacro.CaptureImportKey = importKey ++ runner.recordMacro.CaptureBoard = token.Board ++ runner.recordMacro.CaptureID = token.ID ++ runner.recordMacro.CaptureStartedAtUS = token.StartedAtUS ++ // accepted_steps is the exact retained prefix. dropped_steps counts the ++ // first action that did not fit and stopped local capture; a continuously ++ // connected host still receives that Action event, while recovery-only ++ // clients truthfully report it missing. ++ expected := int(status.AcceptedSteps) + int(status.DroppedSteps) ++ missing := 0 ++ if expected > len(runner.recordMacro.Steps) { ++ missing = expected - len(runner.recordMacro.Steps) ++ } ++ if missing > 65535 { ++ missing = 65535 ++ } ++ runner.recordMacro.CaptureMissingSteps = uint16(missing) ++ runner.recording.Steps = len(runner.recordMacro.Steps) ++ runner.recording.DroppedSteps = status.DroppedSteps ++ runner.recording.LastError = "" ++ runner.recordMu.Unlock() ++ ++ macro, err := runner.StopRecording(true) ++ if err != nil { ++ runner.finishBoardCaptureLocked(token, false) ++ runner.publishBoardCaptureFailure(token, err) ++ return ++ } ++ acknowledged := true ++ if err := runner.ackCapture(token); err != nil { ++ acknowledged = false ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recovery", Lifecycle: "ack-pending", State: "saved", ++ Reason: err.Error(), Text: fmt.Sprintf( ++ "board capture %d was saved as %d/%s but export acknowledgement failed: %v", ++ status.ID, macro.ID, macro.Name, err, ++ ), ++ }) ++ } ++ runner.finishBoardCaptureLocked(token, acknowledged) ++ lifecycle := "saved" ++ if macro.CaptureMissingSteps != 0 { ++ lifecycle = "saved-truncated" ++ } ++ if !acknowledged { ++ lifecycle += "-ack-pending" ++ } ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recording", Lifecycle: lifecycle, State: lifecycle, ++ Text: fmt.Sprintf( ++ "board capture %d saved as %d/%s with %d steps; dropped=%d missing=%d", ++ status.ID, macro.ID, macro.Name, len(macro.Steps), ++ macro.CaptureDroppedSteps, macro.CaptureMissingSteps, ++ ), ++ }) ++} ++ ++func boardCaptureImportKey(token boardCaptureToken, stream []byte) string { ++ hash := sha256.New() ++ _, _ = hash.Write([]byte("pccontroller-board-capture-v1\x00")) ++ _, _ = hash.Write([]byte(token.Board)) ++ _, _ = hash.Write([]byte{0, token.ID}) ++ var identity [4]byte ++ binary.LittleEndian.PutUint32(identity[:], token.StartedAtUS) ++ _, _ = hash.Write(identity[:]) ++ _, _ = hash.Write(stream) ++ return hex.EncodeToString(hash.Sum(nil)) ++} ++ ++func (runner *MacroRunner) importedBoardCapture(importKey string) (appconfig.Macro, bool) { ++ for _, macro := range runner.List() { ++ if macro.CaptureImportKey == importKey { ++ return macro, true ++ } ++ } ++ return appconfig.Macro{}, false ++} ++ ++func (runner *MacroRunner) ackBoardCaptureExport(token boardCaptureToken) error { ++ ctx, cancel := context.WithTimeout(context.Background(), macroRequestTimeout) ++ defer cancel() ++ _, err := runner.runtime.requestAtGeneration( ++ ctx, token.Generation, native.OpMacroStep, ++ native.MacroCaptureAcknowledgePayload(token.ID, token.StartedAtUS), ++ native.OpACK, ++ ) ++ return err ++} ++ ++func (runner *MacroRunner) finishBoardCapture(token boardCaptureToken, completed bool) { ++ runner.boardCaptureMu.Lock() ++ runner.finishBoardCaptureLocked(token, completed) ++ runner.boardCaptureMu.Unlock() ++} ++ ++func (runner *MacroRunner) finishBoardCaptureLocked(token boardCaptureToken, completed bool) { ++ if token.Generation != runner.boardCaptureGeneration { ++ return ++ } ++ delete(runner.boardCaptureFinalizing, token) ++ if completed { ++ runner.boardCaptureFinished[token] = struct{}{} ++ } ++} ++ ++func (runner *MacroRunner) publishBoardCaptureFailure(token boardCaptureToken, err error) { ++ runner.recordMu.Lock() ++ if runner.recording.Active && runner.recording.BoardOwned && ++ runner.recordCapture == token { ++ runner.recording.LastError = err.Error() ++ } ++ runner.recordMu.Unlock() ++ runner.runtime.PublishStructuredEvent(Event{ ++ Kind: "macro.recording", Lifecycle: "failed", State: "error", ++ Reason: err.Error(), Text: fmt.Sprintf("recover board capture %d: %v", token.ID, err), ++ }) ++} ++ ++func (runner *MacroRunner) fetchBoardCapture(token boardCaptureToken) ([]byte, error) { ++ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ++ defer cancel() ++ var stream []byte ++ var total uint16 ++ for first := true; first || len(stream) < int(total); first = false { ++ frame, err := runner.runtime.requestAtGeneration( ++ ctx, token.Generation, native.OpMacroStep, ++ native.MacroCaptureQueryPayload(token.ID, uint16(len(stream))), ++ native.OpMacroStatus, ++ ) ++ if err != nil { ++ return nil, fmt.Errorf("query capture at byte %d: %w", len(stream), err) ++ } ++ chunk, err := native.ParseMacroCaptureChunk(frame.Payload) ++ if err != nil { ++ return nil, err ++ } ++ if chunk.ID != token.ID || int(chunk.Offset) != len(stream) || ++ (!first && chunk.TotalBytes != total) { ++ return nil, fmt.Errorf( ++ "capture identity/range changed: want id=%d offset=%d total=%d, got id=%d offset=%d total=%d", ++ token.ID, len(stream), total, chunk.ID, chunk.Offset, chunk.TotalBytes, ++ ) ++ } ++ total = chunk.TotalBytes ++ stream = append(stream, chunk.Data...) ++ } ++ return stream, nil ++} ++ ++func decodeMacroCaptureStream(stream []byte) ([]appconfig.MacroStep, error) { ++ steps := make([]appconfig.MacroStep, 0) ++ for offset := 0; offset < len(stream); { ++ if len(stream)-offset < native.MacroRecordHeaderSize { ++ return nil, fmt.Errorf("capture ends in a %d-byte record header", len(stream)-offset) ++ } ++ due := binary.LittleEndian.Uint32(stream[offset : offset+4]) ++ opcode := stream[offset+4] ++ length := int(stream[offset+5]) ++ end := offset + native.MacroRecordHeaderSize + length ++ required, recordable := native.MacroBoardActionPayloadLength(opcode) ++ if !recordable || length != int(required) || end > len(stream) { ++ return nil, fmt.Errorf("invalid captured record at byte %d", offset) ++ } ++ step, ok := recordedMacroStep(ActionEvidence{ ++ Opcode: opcode, Payload: append([]byte(nil), stream[offset+6:end]...), ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: due, Timed: true, ++ }) ++ if !ok { ++ return nil, fmt.Errorf("captured opcode 0x%02X payload is invalid", opcode) ++ } ++ step.AtUS = due ++ if len(steps) != 0 && step.AtUS < steps[len(steps)-1].AtUS { ++ return nil, errors.New("captured step timing is not ordered") ++ } ++ steps = append(steps, step) ++ offset = end ++ } ++ return steps, nil ++} ++ ++func mergeRecordedMacroSteps(current, recovered []appconfig.MacroStep) []appconfig.MacroStep { ++ result := append([]appconfig.MacroStep(nil), current...) ++ available := make(map[string]int) ++ for _, step := range current { ++ available[macroStepIdentity(step)]++ ++ } ++ seen := make(map[string]int) ++ for _, step := range recovered { ++ key := macroStepIdentity(step) ++ seen[key]++ ++ if seen[key] > available[key] { ++ result = append(result, step) ++ } ++ } ++ sort.SliceStable(result, func(i, j int) bool { return result[i].AtUS < result[j].AtUS }) ++ return result ++} ++ ++func macroStepIdentity(step appconfig.MacroStep) string { ++ opcode, payload, err := compileMacroCommand(step) ++ if err != nil { ++ return fmt.Sprintf("invalid:%#v", step) ++ } ++ record, err := native.EncodeMacroRecord(step.AtUS, opcode, payload) ++ if err != nil { ++ return fmt.Sprintf("invalid:%#v", step) ++ } ++ return string(record) ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + // Start validates and buffers a macro before arming the MCU playback epoch. +@@ -345,7 +1059,8 @@ func (runner *MacroRunner) Start(ctx context.Context, reference string) (MacroSt + tolerance = defaultMacroTimingToleranceUS + } + runner.state = MacroState{ +- Running: true, ID: macro.ID, Name: macro.Name, ++ Running: true, Generation: snapshot.Generation, ++ ID: macro.ID, Name: macro.Name, + Category: macro.Category, Color: normalizedMacroColor(macro.Color), + StepCount: len(compiled.steps), DurationUS: compiled.durationUS, + StartedAt: time.Now(), TimingToleranceUS: tolerance, +@@ -364,7 +1079,7 @@ func (runner *MacroRunner) Start(ctx context.Context, reference string) (MacroSt + begun := false + fail := func(cause error) (MacroState, error) { + if begun { +- if cleanupErr := runner.cancelBoard(false); cleanupErr != nil { ++ if cleanupErr := runner.cancelBoardAtGeneration(snapshot.Generation, false); cleanupErr != nil { + cause = errors.Join(cause, fmt.Errorf("macro cleanup: %w", cleanupErr)) + } + } +@@ -381,19 +1096,19 @@ func (runner *MacroRunner) Start(ctx context.Context, reference string) (MacroSt + if err != nil { + return fail(err) + } +- if _, err = runner.request(ctx, native.OpMacroStart, startPayload, native.OpACK); err != nil { ++ if _, err = runner.request(ctx, snapshot.Generation, native.OpMacroStart, startPayload, native.OpACK); err != nil { + return fail(err) + } + begun = true + afterID := runner.runtime.LatestEventID() +- sent, err := runner.appendBytes(ctx, compiled, 0, native.MacroQueueCapacity) ++ sent, err := runner.appendBytes(ctx, snapshot.Generation, compiled, 0, native.MacroQueueCapacity) + if err != nil { + return fail(err) + } +- if err := runner.showMacroIdentity(ctx, compiled); err != nil { ++ if err := runner.showMacroIdentity(ctx, snapshot.Generation, compiled); err != nil { + runner.runtime.PublishHostEvent("macro.display", "macro identity display unavailable: "+err.Error()) + } +- if _, err = runner.request(ctx, native.OpMacroStep, native.MacroQueueRunPayload(), native.OpACK); err != nil { ++ if _, err = runner.request(ctx, snapshot.Generation, native.OpMacroStep, native.MacroQueueRunPayload(), native.OpACK); err != nil { + return fail(err) + } + +@@ -407,7 +1122,7 @@ func (runner *MacroRunner) Start(ctx context.Context, reference string) (MacroSt + state := runner.state + runner.mu.Unlock() + runner.publishLifecycle("started", state, nil) +- go runner.play(playContext, done, compiled, sent, afterID, lease) ++ go runner.play(playContext, done, snapshot.Generation, compiled, sent, afterID, lease) + return state, nil + } + +@@ -461,6 +1176,7 @@ func (runner *MacroRunner) find(reference string) (appconfig.Macro, error) { + func (runner *MacroRunner) play( + ctx context.Context, + done chan struct{}, ++ generation uint64, + compiled compiledMacro, + sent int, + afterID uint64, +@@ -469,7 +1185,7 @@ func (runner *MacroRunner) play( + defer close(done) + defer lease.Release() + +- status, err := runner.queryBoard(ctx) ++ status, err := runner.queryBoard(ctx, generation) + if err == nil { + runner.applyDeviceStatus(status) + } +@@ -482,12 +1198,17 @@ func (runner *MacroRunner) play( + deadline := time.Now().Add(watchdog) + cancelled := false + for err == nil { ++ playSnapshot := runner.runtime.Snapshot() ++ if !playSnapshot.Connected || playSnapshot.Generation != generation { ++ err = fmt.Errorf("connection generation %d became unavailable during macro playback", generation) ++ break ++ } + if ctx.Err() != nil { + cancelled = true + runner.mu.RLock() + keep := runner.cancelKeep + runner.mu.RUnlock() +- err = runner.cancelBoard(keep) ++ err = runner.cancelBoardAtGeneration(generation, keep) + if err == nil { + status.State = native.MacroCancelled + } +@@ -500,7 +1221,7 @@ func (runner *MacroRunner) play( + + if status.Active() && sent < len(compiled.stream) && status.Free() != 0 { + before := sent +- sent, err = runner.appendBytes(ctx, compiled, sent, int(status.Free())) ++ sent, err = runner.appendBytes(ctx, generation, compiled, sent, int(status.Free())) + if err != nil { + break + } +@@ -521,7 +1242,7 @@ func (runner *MacroRunner) play( + + wait := macroStatusPollInterval - time.Since(lastQuery) + if wait <= 0 { +- status, err = runner.queryBoard(ctx) ++ status, err = runner.queryBoard(ctx, generation) + lastQuery = time.Now() + if err == nil { + runner.applyDeviceStatus(status) +@@ -541,6 +1262,11 @@ func (runner *MacroRunner) play( + err = waitErr + break + } ++ playSnapshot = runner.runtime.Snapshot() ++ if !playSnapshot.Connected || playSnapshot.Generation != generation { ++ err = fmt.Errorf("connection generation %d became unavailable while waiting for macro evidence", generation) ++ break ++ } + afterID = event.ID + if event.Frame.Seq == native.MacroExecutionSequence { + if observed < len(compiled.steps) { +@@ -576,7 +1302,7 @@ func (runner *MacroRunner) play( + runner.mu.RLock() + keep := runner.cancelKeep + runner.mu.RUnlock() +- cancelErr := runner.cancelBoard(keep) ++ cancelErr := runner.cancelBoardAtGeneration(generation, keep) + if cancelErr == nil { + status.State = native.MacroCancelled + err = nil +@@ -586,7 +1312,7 @@ func (runner *MacroRunner) play( + } + + if err != nil && !cancelled { +- if cleanupErr := runner.cancelBoard(false); cleanupErr != nil { ++ if cleanupErr := runner.cancelBoardAtGeneration(generation, false); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("macro safe-stop cleanup: %w", cleanupErr)) + } + } +@@ -595,6 +1321,7 @@ func (runner *MacroRunner) play( + + func (runner *MacroRunner) appendBytes( + ctx context.Context, ++ generation uint64, + compiled compiledMacro, + offset int, + available int, +@@ -615,7 +1342,7 @@ func (runner *MacroRunner) appendBytes( + if err != nil { + return offset, err + } +- if _, err := runner.request(ctx, native.OpMacroStep, payload, native.OpACK); err != nil { ++ if _, err := runner.request(ctx, generation, native.OpMacroStep, payload, native.OpACK); err != nil { + return offset, err + } + offset += length +@@ -624,9 +1351,9 @@ func (runner *MacroRunner) appendBytes( + return offset, nil + } + +-func (runner *MacroRunner) queryBoard(ctx context.Context) (native.MacroStatus, error) { ++func (runner *MacroRunner) queryBoard(ctx context.Context, generation uint64) (native.MacroStatus, error) { + frame, err := runner.request( +- ctx, ++ ctx, generation, + native.OpMacroStep, + native.MacroQueueQueryPayload(), + native.OpMacroStatus, +@@ -639,28 +1366,42 @@ func (runner *MacroRunner) queryBoard(ctx context.Context) (native.MacroStatus, + + func (runner *MacroRunner) request( + ctx context.Context, ++ generation uint64, + opcode byte, + payload []byte, + expected byte, + ) (native.Frame, error) { + requestContext, cancel := context.WithTimeout(ctx, macroRequestTimeout) + defer cancel() +- return runner.runtime.Request(requestContext, opcode, payload, expected) ++ return runner.runtime.requestAtGeneration( ++ requestContext, generation, opcode, payload, expected, ++ ) + } + + func (runner *MacroRunner) cancelBoard(keepOutputs bool) error { ++ state := runner.State() ++ generation := runner.runtime.Snapshot().Generation ++ if state.Running && state.Generation != 0 { ++ generation = state.Generation ++ } ++ return runner.cancelBoardAtGeneration(generation, keepOutputs) ++} ++ ++func (runner *MacroRunner) cancelBoardAtGeneration(generation uint64, keepOutputs bool) error { + ctx, cancel := context.WithTimeout(context.Background(), macroRequestTimeout) + defer cancel() +- _, err := runner.runtime.Request( +- ctx, +- native.OpMacroCancel, +- native.MacroQueueCancelPayload(keepOutputs), +- native.OpACK, ++ _, err := runner.runtime.requestAtGeneration( ++ ctx, generation, native.OpMacroCancel, ++ native.MacroQueueCancelPayload(keepOutputs), native.OpACK, + ) + return err + } + +-func (runner *MacroRunner) showMacroIdentity(ctx context.Context, compiled compiledMacro) error { ++func (runner *MacroRunner) showMacroIdentity( ++ ctx context.Context, ++ generation uint64, ++ compiled compiledMacro, ++) error { + macro := compiled.definition + label := strings.TrimSpace(macro.Label) + if label == "" { +@@ -678,7 +1419,7 @@ func (runner *MacroRunner) showMacroIdentity(ctx context.Context, compiled compi + if err != nil { + return err + } +- if _, err := runner.request(ctx, native.OpDisplayText, payload, native.OpACK); err != nil { ++ if _, err := runner.request(ctx, generation, native.OpDisplayText, payload, native.OpACK); err != nil { + return err + } + } +@@ -687,7 +1428,7 @@ func (runner *MacroRunner) showMacroIdentity(ctx context.Context, compiled compi + if err != nil { + return err + } +- if _, err := runner.request(ctx, native.OpDisplayText, payload, native.OpACK); err != nil { ++ if _, err := runner.request(ctx, generation, native.OpDisplayText, payload, native.OpACK); err != nil { + return err + } + } +@@ -916,7 +1657,11 @@ func compileMacroCommand(step appconfig.MacroStep) (byte, []byte, error) { + } + return native.OpMenuAction, []byte{step.Target}, nil + case "raw", "opcode": ++<<<<<<< HEAD + if !macroQueueableOpcode(step.Opcode) { ++======= ++ if !native.MacroPlaybackAllowed(step.Opcode) { ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + return 0, nil, fmt.Errorf("opcode 0x%02X is not a queueable acknowledged command", step.Opcode) + } + payload, err := decodeMacroHex(step.PayloadHex) +@@ -958,6 +1703,10 @@ func decodeMacroHex(value string) ([]byte, error) { + func recordedMacroStep(evidence CommandEvidence) (appconfig.MacroStep, bool) { + payload := evidence.Payload + step := appconfig.MacroStep{} ++ required, recordable := native.MacroBoardActionPayloadLength(evidence.Opcode) ++ if !recordable || len(payload) != int(required) { ++ return step, false ++ } + switch evidence.Opcode { + case native.OpRelaySet: + if len(payload) != 2 { +diff --git a/Tools/Controller/internal/control/macros_test.go b/Tools/Controller/internal/control/macros_test.go +index b859dbcc..9517c581 100644 +--- a/Tools/Controller/internal/control/macros_test.go ++++ b/Tools/Controller/internal/control/macros_test.go +@@ -1,13 +1,82 @@ + package control + + import ( ++ "context" + "encoding/binary" ++<<<<<<< HEAD ++======= ++ "strings" ++ "sync" ++ "sync/atomic" ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + "testing" + + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/native" + ) + ++<<<<<<< HEAD ++======= ++type macroCaptureTestStore struct { ++ mu sync.RWMutex ++ config appconfig.Config ++ saved chan struct{} ++} ++ ++func newMacroCaptureTestRunner(t *testing.T) (*Runtime, *MacroRunner, *macroCaptureTestStore) { ++ t.Helper() ++ runtime := New(Options{}) ++ store := ¯oCaptureTestStore{config: appconfig.Defaults(), saved: make(chan struct{}, 8)} ++ runner := NewMacroRunner( ++ runtime, ++ func() []appconfig.Macro { ++ store.mu.RLock() ++ defer store.mu.RUnlock() ++ return append([]appconfig.Macro(nil), store.config.Macros...) ++ }, ++ func() appconfig.Config { ++ store.mu.RLock() ++ defer store.mu.RUnlock() ++ return store.config ++ }, ++ func(change func(*appconfig.Config) error) error { ++ store.mu.Lock() ++ defer store.mu.Unlock() ++ before := len(store.config.Macros) ++ if err := change(&store.config); err != nil { ++ return err ++ } ++ if err := store.config.Validate(); err != nil { ++ return err ++ } ++ if len(store.config.Macros) > before { ++ store.saved <- struct{}{} ++ } ++ return nil ++ }, ++ ) ++ runner.ackCapture = func(boardCaptureToken) error { return nil } ++ return runtime, runner, store ++} ++ ++func (store *macroCaptureTestStore) macros() []appconfig.Macro { ++ store.mu.RLock() ++ defer store.mu.RUnlock() ++ return append([]appconfig.Macro(nil), store.config.Macros...) ++} ++ ++func waitRecordingInactive(t *testing.T, runner *MacroRunner) { ++ t.Helper() ++ deadline := time.Now().Add(time.Second) ++ for runner.RecordingState().Active && time.Now().Before(deadline) { ++ time.Sleep(time.Millisecond) ++ } ++ if runner.RecordingState().Active { ++ t.Fatal("recording did not stop") ++ } ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func TestCompileMacroEncodesOrdinaryOpcodesWithExactOffsets(t *testing.T) { + compiled, err := compileMacro(appconfig.Macro{ + ID: 7, Name: "demo", +@@ -83,8 +152,467 @@ func TestMacroRecorderUsesWrappingMCUAcknowledgementDeltas(t *testing.T) { + } + } + ++<<<<<<< HEAD ++======= ++func TestMacroRecorderCombinesPanelAndRFWithoutDuplicatingHostEcho(t *testing.T) { ++ runtime := New(Options{}) ++ config := appconfig.Defaults() ++ runner := NewMacroRunner( ++ runtime, ++ func() []appconfig.Macro { return config.Macros }, ++ func() appconfig.Config { return config }, ++ func(change func(*appconfig.Config) error) error { ++ if err := change(&config); err != nil { ++ return err ++ } ++ return config.Validate() ++ }, ++ ) ++ if _, err := runner.StartRecording("mixed", "motion", "green"); err != nil { ++ t.Fatal(err) ++ } ++ runner.captureAction(ActionEvidence{ ++ Opcode: native.OpRelaySide, Payload: []byte{0, 1}, ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: 1000, Timed: true, ++ }) ++ runner.captureAction(ActionEvidence{ ++ Opcode: native.OpPWMSet, Payload: []byte{4, 0xFF, 0x0F}, ++ Source: native.InputSourceRF, SourceID: 3, BoardOrigin: true, ++ DeviceMicros: 2750, Timed: true, ++ }) ++ // This is the board echo for a host ACK, not a third activation. ++ runner.captureAction(ActionEvidence{ ++ Opcode: native.OpPWMSet, Payload: []byte{4, 0xFF, 0x0F}, ++ Source: native.InputSourceHost, BoardOrigin: true, ++ DeviceMicros: 2750, Timed: true, ++ }) ++ state := runner.RecordingState() ++ if state.Steps != 2 || state.PanelSteps != 1 || state.RFSteps != 1 || ++ state.HostSteps != 0 || state.LastAtUS != 1750 || ++ state.LastDeltaUS != 1750 || state.LastOpcode != native.OpPWMSet || ++ state.LastSource != native.InputSourceRF { ++ t.Fatalf("unexpected mixed-source recorder state: %#v", state) ++ } ++ macro, err := runner.StopRecording(true) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if len(macro.Steps) != 2 || macro.Steps[0].AtUS != 0 || ++ macro.Steps[1].AtUS != 1750 || macro.Steps[0].Kind != "motion" || ++ macro.Steps[1].Kind != "pwm" { ++ t.Fatalf("unexpected mixed-source macro: %#v", macro) ++ } ++ shown, err := macroCommand(context.Background(), runner, []string{"show", "mixed"}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(shown, "at_us=0") || ++ !strings.Contains(shown, "at_us=1750") || ++ !strings.Contains(shown, "delta_us=1750") { ++ t.Fatalf("exact MCU offsets/deltas missing from macro show:\n%s", shown) ++ } ++} ++ ++func TestBoardRecordingStatusAutoStartsProvisionalHostMacro(t *testing.T) { ++ runtime := New(Options{}) ++ config := appconfig.Defaults() ++ runner := NewMacroRunner( ++ runtime, ++ func() []appconfig.Macro { return config.Macros }, ++ func() appconfig.Config { return config }, ++ func(change func(*appconfig.Config) error) error { ++ if err := change(&config); err != nil { ++ return err ++ } ++ return config.Validate() ++ }, ++ ) ++ runtime.publishMacroStatus(native.MacroStatus{ ++ Schema: native.MacroQueueSchema, State: native.MacroRecording, ID: 7, ++ StartedAtUS: 1000, ++ }) ++ state := runner.RecordingState() ++ if !state.Active || !state.BoardOwned || state.BoardID != 7 || ++ state.ID != 7 || state.Name != "Board capture 7" || state.Category != "board" { ++ t.Fatalf("unexpected board recording state: %#v", state) ++ } ++ runtime.publishActionEvidence(ActionEvidence{ ++ Opcode: native.OpRelaySide, Payload: []byte{1, 2}, ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: 1100, Timed: true, ++ }) ++ if state = runner.RecordingState(); state.Steps != 1 || state.PanelSteps != 1 { ++ t.Fatalf("board action was not captured: %#v", state) ++ } ++ runner.recordMu.RLock() ++ live := append([]appconfig.MacroStep(nil), runner.recordMacro.Steps...) ++ runner.recordMu.RUnlock() ++ recoveredRecord, err := native.EncodeMacroRecord(100, native.OpRelaySide, []byte{1, 2}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ recovered, err := decodeMacroCaptureStream(recoveredRecord) ++ if err != nil { ++ t.Fatal(err) ++ } ++ merged := mergeRecordedMacroSteps(live, recovered) ++ if len(merged) != 1 || merged[0].AtUS != 100 { ++ t.Fatalf("live and recovered board-relative step did not deduplicate: %#v", merged) ++ } ++ if _, err := runner.StopRecording(false); err != nil { ++ t.Fatal(err) ++ } ++} ++ ++func TestBoardCaptureSealsAfterOverflowActionAndDeduplicatesLifecycle(t *testing.T) { ++ runtime, runner, store := newMacroCaptureTestRunner(t) ++ retained, err := native.EncodeMacroRecord(100, native.OpRelaySide, []byte{0, 1}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ fetchStarted := make(chan boardCaptureToken, 1) ++ releaseFetch := make(chan struct{}) ++ var fetchCalls atomic.Int32 ++ runner.fetchCapture = func(token boardCaptureToken) ([]byte, error) { ++ fetchCalls.Add(1) ++ fetchStarted <- token ++ <-releaseFetch ++ return retained, nil ++ } ++ ++ status := native.MacroStatus{ ++ Schema: native.MacroQueueSchema, ID: 7, StartedAtUS: 1000, ++ } ++ status.State = native.MacroRecording ++ runtime.publishMacroStatus(status) ++ runtime.publishActionEvidence(ActionEvidence{ ++ Opcode: native.OpRelaySide, Payload: []byte{0, 1}, ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: 1100, Timed: true, ++ }) ++ // Firmware guarantees this final, non-retained action precedes Captured. ++ runtime.publishActionEvidence(ActionEvidence{ ++ Opcode: native.OpPWMSet, Payload: []byte{2, 0, 8}, ++ Source: native.InputSourceRF, BoardOrigin: true, ++ DeviceMicros: 1200, Timed: true, ++ }) ++ status.State = native.MacroCaptured ++ status.AcceptedSteps = 1 ++ status.DroppedSteps = 1 ++ runtime.publishMacroStatus(status) ++ select { ++ case token := <-fetchStarted: ++ if token.ID != 7 || token.StartedAtUS != 1000 { ++ t.Fatalf("unexpected capture token: %#v", token) ++ } ++ case <-time.After(time.Second): ++ t.Fatal("capture recovery did not start") ++ } ++ ++ // Repeated lifecycle reports must not start another fetch/save. Evidence ++ // received after the synchronous seal must not enter the recording. ++ runtime.publishMacroStatus(status) ++ runtime.publishActionEvidence(ActionEvidence{ ++ Opcode: native.OpRelaySide, Payload: []byte{1, 2}, ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: 1300, Timed: true, ++ }) ++ time.Sleep(10 * time.Millisecond) ++ if calls := fetchCalls.Load(); calls != 1 { ++ t.Fatalf("duplicate Captured status started %d recoveries", calls) ++ } ++ close(releaseFetch) ++ select { ++ case <-store.saved: ++ case <-time.After(time.Second): ++ t.Fatal("sealed board capture was not saved") ++ } ++ ++ macros := store.macros() ++ if len(macros) != 1 || len(macros[0].Steps) != 2 || ++ macros[0].Steps[0].AtUS != 100 || macros[0].Steps[1].AtUS != 200 || ++ macros[0].CaptureDroppedSteps != 1 || macros[0].CaptureMissingSteps != 0 { ++ t.Fatalf("overflow capture was not faithful: %#v", macros) ++ } ++} ++ ++func TestBoardCaptureReconnectGenerationCannotSaveOldRecovery(t *testing.T) { ++ runtime, runner, store := newMacroCaptureTestRunner(t) ++ runtime.mu.Lock() ++ runtime.generation = 10 ++ runtime.mu.Unlock() ++ record, err := native.EncodeMacroRecord(50, native.OpRelaySide, []byte{0, 1}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ fetchStarted := make(chan boardCaptureToken, 1) ++ releaseFetch := make(chan struct{}) ++ runner.fetchCapture = func(token boardCaptureToken) ([]byte, error) { ++ fetchStarted <- token ++ <-releaseFetch ++ return record, nil ++ } ++ status := native.MacroStatus{ ++ Schema: native.MacroQueueSchema, State: native.MacroRecording, ++ ID: 4, StartedAtUS: 4000, ++ } ++ runtime.publishMacroStatus(status) ++ status.State = native.MacroCaptured ++ status.AcceptedSteps = 1 ++ runtime.publishMacroStatus(status) ++ select { ++ case token := <-fetchStarted: ++ if token.Generation != 10 { ++ t.Fatalf("old capture used generation %d", token.Generation) ++ } ++ case <-time.After(time.Second): ++ t.Fatal("old capture recovery did not start") ++ } ++ ++ runtime.mu.Lock() ++ runtime.generation = 11 ++ runtime.mu.Unlock() ++ close(releaseFetch) ++ waitRecordingInactive(t, runner) ++ if macros := store.macros(); len(macros) != 0 { ++ t.Fatalf("stale generation saved against replacement board: %#v", macros) ++ } ++ ++ // The replacement board may reuse the same uint8 ID and starts cleanly. ++ runner.fetchCapture = func(token boardCaptureToken) ([]byte, error) { ++ if token.Generation != 11 || token.StartedAtUS != 9000 { ++ t.Fatalf("replacement capture token=%#v", token) ++ } ++ return record, nil ++ } ++ status.StartedAtUS = 9000 ++ status.State = native.MacroRecording ++ runtime.publishMacroStatus(status) ++ status.State = native.MacroCaptured ++ runtime.publishMacroStatus(status) ++ select { ++ case <-store.saved: ++ case <-time.After(time.Second): ++ t.Fatal("replacement generation capture was not saved") ++ } ++ macros := store.macros() ++ if len(macros) != 1 || macros[0].Name != "Board capture 4" || ++ len(macros[0].Steps) != 1 || macros[0].Steps[0].AtUS != 50 { ++ t.Fatalf("replacement capture was not isolated: %#v", macros) ++ } ++} ++ ++func TestConnectActivelyRecoversCaptureCompletedWhileHostWasAbsent(t *testing.T) { ++ runtime, runner, store := newMacroCaptureTestRunner(t) ++ runtime.mu.Lock() ++ runtime.generation = 22 ++ runtime.mu.Unlock() ++ record, err := native.EncodeMacroRecord(375, native.OpRelaySide, []byte{1, 2}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ var queries atomic.Int32 ++ runner.queryCaptureStatus = func(generation uint64) (native.MacroStatus, error) { ++ queries.Add(1) ++ if generation != 22 { ++ t.Fatalf("status query generation=%d", generation) ++ } ++ return native.MacroStatus{ ++ Schema: native.MacroQueueSchema, State: native.MacroCaptured, ++ ID: 9, StartedAtUS: 7000, AcceptedSteps: 1, ++ }, nil ++ } ++ runner.fetchCapture = func(token boardCaptureToken) ([]byte, error) { ++ if token.Generation != 22 || token.ID != 9 || token.StartedAtUS != 7000 { ++ t.Fatalf("offline recovery token=%#v", token) ++ } ++ return record, nil ++ } ++ ++ // A profile without macro support must not receive an unsupported query. ++ runner.recoverBoardCaptureOnConnect(22, ports.Info{}, native.Hello{}) ++ if queries.Load() != 0 { ++ t.Fatal("macro status queried without advertised support") ++ } ++ runner.recoverBoardCaptureOnConnect(22, ports.Info{}, native.Hello{ ++ Capabilities: native.CapabilityTimedMacroQueue, ++ }) ++ select { ++ case <-store.saved: ++ case <-time.After(time.Second): ++ t.Fatal("offline board capture was not recovered on connect") ++ } ++ macros := store.macros() ++ if queries.Load() != 1 || len(macros) != 1 || ++ macros[0].Name != "Board capture 9" || macros[0].RecordingSource != "board" || ++ len(macros[0].Steps) != 1 || macros[0].Steps[0].AtUS != 375 { ++ t.Fatalf("offline reconnect recovery=%#v queries=%d", macros, queries.Load()) ++ } ++} ++ ++func TestRetainedCaptureReconnectIsDurablyDeduplicatedAndAcknowledged(t *testing.T) { ++ runtime, runner, store := newMacroCaptureTestRunner(t) ++ record, err := native.EncodeMacroRecord(375, native.OpRelaySide, []byte{1, 2}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ runner.fetchCapture = func(boardCaptureToken) ([]byte, error) { return record, nil } ++ var acknowledgements atomic.Int32 ++ runner.ackCapture = func(token boardCaptureToken) error { ++ if token.ID != 9 || token.StartedAtUS != 7000 { ++ t.Fatalf("acknowledgement token=%#v", token) ++ } ++ acknowledgements.Add(1) ++ return nil ++ } ++ status := native.MacroStatus{ ++ Schema: native.MacroQueueSchema, State: native.MacroRecording, ++ ID: 9, StartedAtUS: 7000, AcceptedSteps: 1, ++ } ++ const board = "transport=USB-BOARD-A;vid=1A86;pid=7523;kind=1;build=01234567" ++ runtime.mu.Lock() ++ runtime.generation = 1 ++ runtime.mu.Unlock() ++ runner.handleBoardMacroStatusAtGeneration(status, 1, board) ++ status.State = native.MacroCaptured ++ runner.handleBoardMacroStatusAtGeneration(status, 1, board) ++ select { ++ case <-store.saved: ++ case <-time.After(time.Second): ++ t.Fatal("first retained capture was not saved") ++ } ++ deadline := time.Now().Add(time.Second) ++ for acknowledgements.Load() != 1 && time.Now().Before(deadline) { ++ time.Sleep(time.Millisecond) ++ } ++ macros := store.macros() ++ if len(macros) != 1 || len(macros[0].CaptureImportKey) != 64 || ++ macros[0].CaptureBoard != board || macros[0].CaptureID != 9 || ++ macros[0].CaptureStartedAtUS != 7000 { ++ t.Fatalf("durable capture identity missing: %#v", macros) ++ } ++ ++ // The retained Exported state is intentionally queryable after reconnect. ++ // It must acknowledge the same capture again without creating a duplicate. ++ runtime.mu.Lock() ++ runtime.generation = 2 ++ runtime.mu.Unlock() ++ status.State = native.MacroExported ++ runner.handleBoardMacroStatusAtGeneration(status, 2, board) ++ deadline = time.Now().Add(time.Second) ++ for acknowledgements.Load() != 2 && time.Now().Before(deadline) { ++ time.Sleep(time.Millisecond) ++ } ++ if acknowledgements.Load() != 2 { ++ t.Fatalf("recovered capture acknowledgement count=%d", acknowledgements.Load()) ++ } ++ if macros = store.macros(); len(macros) != 1 { ++ t.Fatalf("reconnect duplicated retained capture: %#v", macros) ++ } ++ if runner.RecordingState().Active { ++ t.Fatal("deduplicated retained capture left a provisional recording active") ++ } ++} ++ ++func TestBoardCaptureRejectsActionFromAnotherConnectionGeneration(t *testing.T) { ++ _, runner, _ := newMacroCaptureTestRunner(t) ++ token := boardCaptureToken{Generation: 7, ID: 4, StartedAtUS: 1000, Board: "board-A"} ++ if _, err := runner.startRecording(&token, "Board capture 4", "board", "green"); err != nil { ++ t.Fatal(err) ++ } ++ runner.captureAction(ActionEvidence{ ++ Opcode: native.OpRelaySide, Payload: []byte{0, 1}, ++ Source: native.InputSourcePhysical, BoardOrigin: true, ++ DeviceMicros: 1100, Timed: true, Generation: 8, ++ }) ++ if state := runner.RecordingState(); state.Steps != 0 { ++ t.Fatalf("replacement-generation action entered capture: %#v", state) ++ } ++ if _, err := runner.StopRecording(false); err != nil { ++ t.Fatal(err) ++ } ++} ++ ++func TestCaptureStreamDecodeMergeAndMetadataUpdate(t *testing.T) { ++ first, err := native.EncodeMacroRecord(100, native.OpRelaySide, []byte{0, 1}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ second, err := native.EncodeMacroRecord(250, native.OpPWMSet, []byte{2, 0x00, 0x08}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ steps, err := decodeMacroCaptureStream(append(first, second...)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ merged := mergeRecordedMacroSteps(steps[:1], steps) ++ if len(merged) != 2 || merged[0].AtUS != 100 || merged[1].AtUS != 250 || ++ merged[0].Kind != "motion" || merged[1].Kind != "pwm" { ++ t.Fatalf("unexpected recovered steps: %#v", merged) ++ } ++ ++ runtime := New(Options{}) ++ config := appconfig.Defaults() ++ config.Macros = []appconfig.Macro{{ ++ ID: 7, Name: "Board capture 7", Category: "board", Color: "green", ++ RecordingSource: "board", Steps: merged, ++ }} ++ runner := NewMacroRunner( ++ runtime, ++ func() []appconfig.Macro { return config.Macros }, ++ func() appconfig.Config { return config }, ++ func(change func(*appconfig.Config) error) error { ++ if err := change(&config); err != nil { ++ return err ++ } ++ return config.Validate() ++ }, ++ ) ++ category, color := "motion", "purple" ++ updated, err := runner.UpdateMetadata("7", "Night lift", &category, &color) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if updated.Name != "Night lift" || updated.Category != "motion" || ++ updated.Color != "violet" || updated.RecordingSource != "board" || ++ len(updated.Steps) != 2 { ++ t.Fatalf("metadata update changed capture data: %#v", updated) ++ } ++ preserved, err := runner.UpdateMetadata("7", "Night lift renamed", nil, nil) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if preserved.Category != "motion" || preserved.Color != "violet" { ++ t.Fatalf("rename erased omitted metadata: %#v", preserved) ++ } ++ clear := "-" ++ cleared, err := runner.UpdateMetadata("7", "Night lift renamed", &clear, &clear) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if cleared.Category != "" || cleared.Color != "" { ++ t.Fatalf("explicit metadata clear was ignored: %#v", cleared) ++ } ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func TestMacroExplicitSafeCancelOverridesBeginKeepPreference(t *testing.T) { + if payload := native.MacroQueueCancelPayload(false); len(payload) != 1 || payload[0] != 0 { + t.Fatalf("safe cancel must be explicit zero, got %v", payload) + } + } ++ ++func TestRecordedMacroStepRequiresCanonicalFixedPayloadShape(t *testing.T) { ++ for _, evidence := range []ActionEvidence{ ++ {Opcode: native.OpRelayAllOff, Payload: []byte{1}}, ++ {Opcode: native.OpPWMAllOff, Payload: []byte{0}}, ++ {Opcode: native.OpRelaySet, Payload: []byte{5}}, ++ {Opcode: native.OpStatusEffect, Payload: []byte{1, 2, 3}}, ++ } { ++ if step, ok := recordedMacroStep(evidence); ok { ++ t.Fatalf("malformed/playback-only evidence accepted: %#v => %#v", evidence, step) ++ } ++ } ++} +diff --git a/Tools/Controller/internal/control/programming_lifecycle.go b/Tools/Controller/internal/control/programming_lifecycle.go +index 562ea52a..db8d82be 100644 +--- a/Tools/Controller/internal/control/programming_lifecycle.go ++++ b/Tools/Controller/internal/control/programming_lifecycle.go +@@ -38,9 +38,8 @@ type ProgrammingLifecycleOptions struct { + Wait func(context.Context, time.Duration) error + Outputs *OutputScheduler + HostConfig func() appconfig.Config +- // ReinitializeEEPROM is an explicit development exception for a +- // connected board whose settings payload cannot be decoded by this host. +- // It never teaches the firmware or protocol about an obsolete layout. ++ // ReinitializeEEPROM is an explicit alpha-layout migration. The raw backup ++ // remains mandatory, but only compatible semantic fields cross layouts. + ReinitializeEEPROM bool + } + +@@ -95,9 +94,8 @@ type ProgrammingSession struct { + RecoveryMarkerPath string `json:"-"` + OriginalSettings *native.Settings `json:"original_mcu_eeprom_settings,omitempty"` + OriginalLiveState *ProgrammingLiveState `json:"original_live_state,omitempty"` +- // ReinitializeEEPROM deliberately discards incompatible semantic settings +- // after the mandatory raw EEPROM backup. This is tooling-only development +- // state and is not a firmware migration or compatibility mechanism. ++ // ReinitializeEEPROM semantically migrates a validated raw backup into the ++ // compiled layout; superseded raw bytes are never replayed. + ReinitializeEEPROM bool `json:"reinitialize_eeprom,omitempty"` + SettingsQueryError string `json:"settings_query_error,omitempty"` + PostFlashSettings *native.Settings `json:"post_flash_mcu_eeprom_settings,omitempty"` +@@ -463,11 +461,9 @@ func prepareProgrammingSession( + settings, settingsErr := device.QuerySettings(ctx) + if settingsErr != nil { + snapshot.SettingsQueryError = settingsErr.Error() +- if !options.ReinitializeEEPROM { +- return nil, fmt.Errorf( +- "capture exact MCU EEPROM settings before programming: %w", settingsErr, +- ) +- } ++ return nil, fmt.Errorf( ++ "capture semantic MCU EEPROM settings before programming: %w", settingsErr, ++ ) + } else if settings.Flags&native.SettingsProgrammingMode != 0 { + return nil, errors.New( + "board programming safety latch is already active; recover the pending programming session first", +@@ -483,9 +479,7 @@ func prepareProgrammingSession( + liveState, liveStateErr := device.CaptureLiveState(ctx) + if liveStateErr != nil { + snapshot.LiveStateQueryError = liveStateErr.Error() +- if !options.ReinitializeEEPROM { +- return nil, fmt.Errorf("capture live board outputs before programming: %w", liveStateErr) +- } ++ return nil, fmt.Errorf("capture live board outputs before programming: %w", liveStateErr) + } + snapshot.LiveState = &liveState + snapshotPath, err := persistBoardSettingsSnapshot(options.DataPaths, snapshot) +@@ -502,15 +496,7 @@ func prepareProgrammingSession( + } + if options.ReinitializeEEPROM { + session.Warnings = append(session.Warnings, +- "DEVELOPMENT EEPROM REINITIALIZATION: the pre-flash raw EEPROM backup is retained, but incompatible board settings and live outputs will not be restored") +- if snapshot.SettingsQueryError != "" { +- session.Warnings = append(session.Warnings, +- "current MCU settings could not be decoded and were captured as an error: "+snapshot.SettingsQueryError) +- } +- if snapshot.LiveStateQueryError != "" { +- session.Warnings = append(session.Warnings, +- "live-state capture was partial; the board will be forced directly off: "+snapshot.LiveStateQueryError) +- } ++ "DEVELOPMENT EEPROM MIGRATION: the untouched raw backup is retained; compatible settings and live outputs will be restored semantically into the compiled layout") + } + markerPath, err := persistProgrammingMarker(options.DataPaths, session) + if err != nil { +@@ -531,17 +517,7 @@ func prepareProgrammingSession( + return session, err + } + if err := device.RampPWMToZero(ctx, liveState); err != nil { +- if !options.ReinitializeEEPROM { +- return session, fmt.Errorf("gracefully fade PWM outputs before programming (recovery marker retained): %w", err) +- } +- session.Warnings = append(session.Warnings, +- "PWM fade was unavailable; applying an immediate all-output safe state: "+err.Error()) +- if safeErr := device.EnterSafeProgrammingState(ctx); safeErr != nil { +- return session, fmt.Errorf( +- "force relays/PWM/macro off for EEPROM reinitialization (recovery marker retained): %w", +- safeErr, +- ) +- } ++ return session, fmt.Errorf("gracefully fade PWM outputs before programming (recovery marker retained): %w", err) + } + if err := advanceProgrammingSessionPhase(device, session, "outputs-ramped"); err != nil { + return session, err +@@ -597,7 +573,7 @@ func prepareProgrammingSession( + "pre-flash EEPROM mute/latch was unavailable because the old settings payload is intentionally unsupported; outputs are live-safe and the completed power-down melody will not be followed by the host buzzer") + } else { + session.Warnings = append(session.Warnings, +- "pre-flash EEPROM was deliberately left byte-for-byte untouched so the mandatory raw backup preserves the original development settings; live outputs are safe and the host buzzer has stopped") ++ "pre-flash EEPROM remains byte-for-byte untouched until the mandatory raw backup completes; the post-backup gate will then program a schema-migrated Silent/Prog image before flash") + } + session.SafeStateApplied = true + finalPreparationPhase := "latched-safe" +@@ -832,10 +808,10 @@ func restoreProgrammingSession( + return nil + } + +-// finalizeDevelopmentEEPROMReinitialization validates the newly flashed +-// firmware's current settings schema, then commits the host-owned canonical +-// defaults. It never imports rich defaults from AVR flash and intentionally +-// does not decode or restore superseded semantic settings from the raw backup. ++// finalizeDevelopmentEEPROMReinitialization authenticates the newly flashed ++// schema, then restores the captured semantic settings behind the Prog latch. ++// Raw records are never replayed across alpha layouts; menu-layout-only fields ++// are intentionally dropped by the pre-flash schema-1 migration. + func finalizeDevelopmentEEPROMReinitialization( + ctx context.Context, + device programmingDevice, +@@ -845,61 +821,64 @@ func finalizeDevelopmentEEPROMReinitialization( + warningStart int, + live Snapshot, + ) error { ++ if session.OriginalSettings == nil || session.OriginalLiveState == nil { ++ return retainProgrammingSafeState(ctx, device, session, options, errors.New( ++ "semantic EEPROM migration lacks captured settings/live state", ++ )) ++ } + queried, err := device.QuerySettings(ctx) + if err != nil { + return retainProgrammingSafeState(ctx, device, session, options, fmt.Errorf( +- "query current firmware defaults after development EEPROM reinitialization: %w", err, ++ "query migrated settings after application HELLO: %w", err, + )) + } +- if queried.Flags&native.SettingsProgrammingMode != 0 { +- session.Warnings = append(session.Warnings, +- "current-schema defaults retained the interrupted programming latch; the verified development reinitialization will clear it as its final settings write") ++ if queried.Flags&native.SettingsProgrammingMode == 0 { ++ return retainProgrammingSafeState(ctx, device, session, options, errors.New( ++ "migrated settings did not retain the Prog latch through first HELLO", ++ )) + } + session.PostFlashSettings = &queried + if err := advanceProgrammingSessionPhase(device, session, "development-defaults-queried"); err != nil { + return err + } +- +- // The Go host is the canonical factory-default owner. The AVR value queried +- // above proves only that the new schema is operational; it is not used as a +- // source of descriptive defaults. +- finalSettings := native.DefaultSettings() +- finalSettings.Persisted = true +- finalSettings.Flags &^= native.SettingsSilent | native.SettingsProgrammingMode +- finalSettings.LightMode = 0 +- finalSettings.OutputPersistence = 0 +- finalSettings.RelayRestoreMask = 0 + if err := device.EnterSafeProgrammingState(ctx); err != nil { + return retainProgrammingSafeState(ctx, device, session, options, fmt.Errorf( +- "de-energize new firmware before committing reinitialized settings: %w", err, ++ "de-energize new firmware before semantic restore: %w", err, + )) + } ++ original := *session.OriginalSettings ++ original.Flags &^= native.SettingsProgrammingMode ++ latched := original ++ latched.Flags |= native.SettingsProgrammingMode + if err := storeAndVerifyProgrammingSettings( +- ctx, device, finalSettings, options, +- "commit host-owned current-schema defaults with Silent off and outputs disabled", ++ ctx, device, latched, options, ++ "stage migrated semantic settings behind programming latch", + ); err != nil { + return retainProgrammingSafeState(ctx, device, session, options, err) + } +- if err := device.EnterSafeProgrammingState(ctx); err != nil { +- return retainProgrammingSafeState(ctx, device, session, options, fmt.Errorf( +- "verify relays/PWM/macro remain off after EEPROM reinitialization: %w", err, +- )) ++ if err := advanceProgrammingSessionPhase(device, session, "settings-staged"); err != nil { ++ return retainProgrammingSafeState(ctx, device, session, options, err) + } +- confirmed, err := device.QuerySettings(ctx) ++ if err := storeAndVerifyProgrammingSettings( ++ ctx, device, original, options, ++ "clear programming latch after migrated settings verification", ++ ); err != nil { ++ return retainProgrammingSafeState(ctx, device, session, options, err) ++ } ++ if err := advanceProgrammingSessionPhase(device, session, "latch-cleared"); err != nil { ++ return retainProgrammingSafeState(ctx, device, session, options, err) ++ } ++ warnings, err := device.RestoreLiveState(ctx, *session.OriginalLiveState) + if err != nil { + return retainProgrammingSafeState(ctx, device, session, options, fmt.Errorf( +- "read back reinitialized MCU settings: %w", err, ++ "restore captured live state after semantic migration: %w", err, + )) + } +- if confirmed != finalSettings || confirmed.Flags&native.SettingsSilent != 0 || +- confirmed.Flags&native.SettingsProgrammingMode != 0 || +- confirmed.LightMode != 0 || confirmed.OutputPersistence != 0 || +- confirmed.RelayRestoreMask != 0 { +- return retainProgrammingSafeState(ctx, device, session, options, errors.New( +- "reinitialized settings failed audible/off/read-back invariants", +- )) ++ session.Warnings = append(session.Warnings, warnings...) ++ session.PostFlashSettings = &original ++ if err := advanceProgrammingSessionPhase(device, session, "live-restored"); err != nil { ++ return retainProgrammingSafeState(ctx, device, session, options, err) + } +- session.PostFlashSettings = &confirmed + if err := advanceProgrammingSessionPhase(device, session, "development-reinitialized"); err != nil { + return err + } +@@ -915,11 +894,13 @@ func finalizeDevelopmentEEPROMReinitialization( + "physical LCD completion message unavailable: "+err.Error()) + } + } +- if err := device.PlayProgrammingMelody( +- ctx, programmingReadyMelody(options.HostConfig), +- ); err != nil { +- session.Warnings = append(session.Warnings, +- "programming-ready melody unavailable: "+err.Error()) ++ if original.Flags&native.SettingsSilent == 0 { ++ if err := device.PlayProgrammingMelody( ++ ctx, programmingReadyMelody(options.HostConfig), ++ ); err != nil { ++ session.Warnings = append(session.Warnings, ++ "programming-ready melody unavailable: "+err.Error()) ++ } + } + if err := os.Remove(session.RecoveryMarkerPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove completed EEPROM reinitialization recovery marker: %w", err) +@@ -927,9 +908,9 @@ func finalizeDevelopmentEEPROMReinitialization( + writeProgrammingWarningsFrom(output, session, warningStart) + if output != nil { + fmt.Fprintln(output, +- "DEVELOPMENT EEPROM REINITIALIZED: old semantic settings were not restored; raw pre-flash EEPROM remains in the verified backup") ++ "DEVELOPMENT EEPROM MIGRATED: compatible settings and live state restored semantically; raw pre-flash EEPROM remains in the verified backup") + fmt.Fprintln(output, +- "current-schema settings verified with Silent off and relay/PWM/macro outputs off; recovery marker cleared") ++ "schema-1 settings verified and Prog cleared only after HELLO; recovery marker cleared") + } + return nil + } +@@ -1010,7 +991,7 @@ func reassertProgrammingSession( + if err := device.EnterSafeProgrammingState(ctx); err != nil { + return fmt.Errorf("reassert safe relays/PWM/macro state: %w", err) + } +- if session.OriginalSettings != nil && !session.ReinitializeEEPROM { ++ if session.OriginalSettings != nil { + safe := programmingSafeSettings(*session.OriginalSettings) + if err := storeAndVerifyProgrammingSettings( + ctx, device, safe, options, "reassert safe EEPROM settings", +@@ -1115,7 +1096,7 @@ func retainProgrammingSafeState( + "reassert safe live outputs after restore failure: %w", err, + )) + } +- if session.OriginalSettings != nil && !session.ReinitializeEEPROM { ++ if session.OriginalSettings != nil { + if err := storeAndVerifyProgrammingSettings( + ctx, device, programmingSafeSettings(*session.OriginalSettings), options, + "re-persist programming latch after restore failure", +diff --git a/Tools/Controller/internal/control/programming_lifecycle_test.go b/Tools/Controller/internal/control/programming_lifecycle_test.go +index 42ca1437..82c6d8b0 100644 +--- a/Tools/Controller/internal/control/programming_lifecycle_test.go ++++ b/Tools/Controller/internal/control/programming_lifecycle_test.go +@@ -450,7 +450,7 @@ func TestProgrammingLifecycleSettingsQueryFailureStopsBeforeMutation(t *testing. + context.Background(), device, firmware, + ProgrammingLifecycleOptions{DataPaths: paths, Wait: noProgrammingWait}, io.Discard, + ) +- if err == nil || !strings.Contains(err.Error(), "capture exact MCU EEPROM settings") { ++ if err == nil || !strings.Contains(err.Error(), "capture semantic MCU EEPROM settings") { + t.Fatalf("settings query failure was not rejected: session=%+v err=%v", session, err) + } + if session != nil || len(device.stores) != 0 || device.safeCalls != 0 { +@@ -458,80 +458,21 @@ func TestProgrammingLifecycleSettingsQueryFailureStopsBeforeMutation(t *testing. + } + } + +-func TestProgrammingLifecycleDevelopmentEEPROMReinitializationCapturesErrorAndKeepsOutputsOff(t *testing.T) { ++func TestProgrammingLifecycleDevelopmentEEPROMMigrationRejectsMissingSemanticCapture(t *testing.T) { + paths, firmware := programmingLifecycleFixture(t) + queryErr := errors.New("SETTINGS payload length 13, expected 15") +- liveErr := errors.New("PWM_VALUES unavailable on development firmware") + device := &fakeProgrammingDevice{ +- snapshot: connectedProgrammingSnapshot( +- native.CapabilityHostFrontPanel | native.CapabilityI2CTransfer, +- ), ++ snapshot: connectedProgrammingSnapshot(native.CapabilityHostFrontPanel), + queryErr: queryErr, +- liveErr: liveErr, +- rampErr: liveErr, +- liveState: ProgrammingLiveState{ +- PWM: &native.PWMValues{Available: true, Values: [16]uint16{120, 240}}, +- }, + } + options := ProgrammingLifecycleOptions{ + DataPaths: paths, Wait: noProgrammingWait, ReinitializeEEPROM: true, + } +- var output strings.Builder + session, err := prepareProgrammingSession( +- context.Background(), device, firmware, options, &output, ++ context.Background(), device, firmware, options, io.Discard, + ) +- if err != nil { +- t.Fatal(err) +- } +- if !session.ReinitializeEEPROM || session.SettingsQueryError != queryErr.Error() || +- session.OriginalSettings != nil || session.OriginalLiveState == nil || +- session.Phase != "development-reinitialize-safe" || len(device.stores) != 0 { +- t.Fatalf("development preparation session=%+v stores=%v", session, device.stores) +- } +- content, err := os.ReadFile(session.SettingsSnapshotPath) +- if err != nil { +- t.Fatal(err) +- } +- if !strings.Contains(string(content), `"settings_query_error": "SETTINGS payload length 13, expected 15"`) || +- !strings.Contains(string(content), `"live_state_query_error": "PWM_VALUES unavailable on development firmware"`) || +- !strings.Contains(output.String(), "DEVELOPMENT EEPROM REINITIALIZATION") { +- t.Fatalf("snapshot/output did not preserve the compatibility exception:\n%s\n%s", content, output.String()) +- } +- +- if err := MarkProgrammingSessionComplete(session, true); err != nil { +- t.Fatal(err) +- } +- device.queryErr = nil +- device.settings = native.Settings{ +- Flags: native.SettingsSilent | native.SettingsProgrammingMode, LightMode: 2, +- OnBrightness: 180, DisplayBrightness: 5, +- StatusBrightness: 128, OutputPersistence: native.OutputPersistenceMask, +- RelayRestoreMask: 0xFF, MotionBreakMSValue: 1, +- } +- if err := restoreProgrammingSession( +- context.Background(), device, session, options, &output, +- ); err != nil { +- t.Fatal(err) +- } +- if len(device.stores) != 1 || device.settings.Flags&native.SettingsSilent != 0 || +- device.settings.Flags&native.SettingsProgrammingMode != 0 || +- device.settings.LightMode != 0 || device.settings.OutputPersistence != 0 || +- device.settings.RelayRestoreMask != 0 || device.safeCalls != 3 || +- device.settings.OnBrightness != native.DefaultSettings().OnBrightness || +- device.settings.DisplayBrightness != native.DefaultSettings().DisplayBrightness || +- device.settings.StatusBrightness != native.DefaultSettings().StatusBrightness || +- strings.Join(device.melodies, ",") != "power-down,programming-ready" { +- t.Fatalf("post-flash development settings were not safe and audible: %+v", device) +- } +- if device.restoredLive != nil { +- t.Fatalf("incompatible live state was restored: %+v", device.restoredLive) +- } +- if _, err := os.Stat(session.RecoveryMarkerPath); !errors.Is(err, os.ErrNotExist) { +- t.Fatalf("verified development marker still exists: %v", err) +- } +- if !strings.Contains(output.String(), "old semantic settings were not restored") || +- !strings.Contains(output.String(), "Silent off") { +- t.Fatalf("data-loss result was not explicit:\n%s", output.String()) ++ if err == nil || session != nil || !strings.Contains(err.Error(), queryErr.Error()) { ++ t.Fatalf("migration accepted missing semantic capture: session=%+v err=%v", session, err) + } + } + +@@ -575,9 +516,26 @@ func TestDevelopmentReinitializationArmsDurableLatchOnlyAfterRawBackup(t *testin + !session.TemporarySilent || device.panelShown != 2 { + t.Fatalf("post-backup latch was not durable/visible: session=%+v device=%+v", session, device) + } ++ if err := MarkProgrammingSessionComplete(session, true); err != nil { ++ t.Fatal(err) ++ } ++ var output strings.Builder ++ if err := restoreProgrammingSession( ++ context.Background(), device, session, options, &output, ++ ); err != nil { ++ t.Fatal(err) ++ } ++ if device.settings != original || len(device.stores) != 3 || ++ device.restoredLive == nil || ++ !strings.Contains(output.String(), "compatible settings and live state restored semantically") { ++ t.Fatalf("semantic post-HELLO restore failed: settings=%+v stores=%d live=%+v output=%q", device.settings, len(device.stores), device.restoredLive, output.String()) ++ } ++ if _, err := os.Stat(session.RecoveryMarkerPath); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("verified migration marker still exists: %v", err) ++ } + } + +-func TestDevelopmentReinitializationDefersUnsupportedLatchToNewFactoryImage(t *testing.T) { ++func TestDevelopmentMigrationNeverFallsBackToUnmigratedFactoryImage(t *testing.T) { + paths, firmware := programmingLifecycleFixture(t) + device := &fakeProgrammingDevice{ + snapshot: connectedProgrammingSnapshot(0), +@@ -589,18 +547,8 @@ func TestDevelopmentReinitializationDefersUnsupportedLatchToNewFactoryImage(t *t + session, err := prepareProgrammingSession( + context.Background(), device, firmware, options, io.Discard, + ) +- if err != nil { +- t.Fatal(err) +- } +- var output strings.Builder +- if err := armProgrammingSessionAfterBackup( +- context.Background(), device, session, options, &output, +- ); err != nil { +- t.Fatal(err) +- } +- if len(device.stores) != 0 || len(session.Warnings) == 0 || +- !strings.Contains(output.String(), "factory EEPROM will arm Prog") { +- t.Fatalf("unsupported latch fallback was not explicit: session=%+v output=%q", session, output.String()) ++ if err == nil || session != nil || !strings.Contains(err.Error(), "obsolete settings schema") { ++ t.Fatalf("unsupported semantic schema reached a raw/factory fallback: session=%+v err=%v", session, err) + } + } + +diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go +index 9fabe7bd..28760d84 100644 +--- a/Tools/Controller/internal/control/runtime.go ++++ b/Tools/Controller/internal/control/runtime.go +@@ -83,6 +83,7 @@ type CommandEvidence struct { + DeviceMicros uint32 `json:"device_micros"` + Timed bool `json:"timed"` + ObservedAt time.Time `json:"observed_at"` ++ Generation uint64 `json:"connection_generation"` + } + + type rfGestureKey struct { +@@ -183,11 +184,22 @@ type Runtime struct { + displayMu sync.Mutex + lcdMessageCancel context.CancelFunc + ++<<<<<<< HEAD + commandObserverMu sync.RWMutex + commandObservers map[uint64]func(CommandEvidence) + nextCommandObserver uint64 + hostMenuRequestMu sync.RWMutex + hostMenuRequestHandler func(native.HostMenuContentRequest) ++======= ++ actionObserverMu sync.RWMutex ++ actionObservers map[uint64]func(ActionEvidence) ++ nextActionObserver uint64 ++ macroStatusObserverMu sync.RWMutex ++ macroStatusObservers map[uint64]func(native.MacroStatus, uint64) ++ nextMacroStatusObserver uint64 ++ hostMenuRequestMu sync.RWMutex ++ hostMenuRequestHandler func(native.HostMenuContentRequest) ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + const programStateHeartbeatPeriod = 2 * time.Second +@@ -690,7 +702,10 @@ func (runtime *Runtime) Request( + payload []byte, + expected ...byte, + ) (native.Frame, error) { +- session := runtime.currentSession() ++ runtime.mu.RLock() ++ session := runtime.session ++ generation := runtime.generation ++ runtime.mu.RUnlock() + if session == nil { + return native.Frame{}, errors.New("device is not connected") + } +@@ -698,16 +713,61 @@ func (runtime *Runtime) Request( + if err != nil { + return native.Frame{}, err + } +- runtime.observe(frame) ++ if !runtime.observeAtGeneration(frame, generation) { ++ return native.Frame{}, fmt.Errorf("connection generation %d changed while the request was in flight", generation) ++ } ++ return frame, nil ++} ++ ++<<<<<<< HEAD ++======= ++// requestAtGeneration pins one request to the authenticated session that ++// produced an asynchronous lifecycle token. It can never fall through to a ++// replacement board after reconnect, even if the old request completes late. ++func (runtime *Runtime) requestAtGeneration( ++ ctx context.Context, ++ generation uint64, ++ opcode byte, ++ payload []byte, ++ expected ...byte, ++) (native.Frame, error) { ++ runtime.mu.RLock() ++ if runtime.generation != generation || runtime.session == nil { ++ runtime.mu.RUnlock() ++ return native.Frame{}, fmt.Errorf("connection generation %d is no longer active", generation) ++ } ++ session := runtime.session ++ runtime.mu.RUnlock() ++ ++ frame, err := session.Request(ctx, opcode, payload, expected...) ++ if err != nil { ++ return native.Frame{}, err ++ } ++ runtime.mu.RLock() ++ current := runtime.generation == generation && runtime.session == session ++ runtime.mu.RUnlock() ++ if !current { ++ return native.Frame{}, fmt.Errorf("connection generation %d changed while the request was in flight", generation) ++ } ++ if !runtime.observeAtGeneration(frame, generation) { ++ return native.Frame{}, fmt.Errorf("connection generation %d changed before its response could be observed", generation) ++ } + return frame, nil + } + ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func (runtime *Runtime) Command( + ctx context.Context, + opcode byte, + payload []byte, + ) error { +- frame, err := runtime.Request(ctx, opcode, payload, native.OpACK) ++ snapshot := runtime.Snapshot() ++ if !snapshot.Connected { ++ return errors.New("device is not connected") ++ } ++ frame, err := runtime.requestAtGeneration( ++ ctx, snapshot.Generation, opcode, payload, native.OpACK, ++ ) + if err != nil { + return err + } +@@ -715,6 +775,7 @@ func (runtime *Runtime) Command( + runtime.publishCommandEvidence(CommandEvidence{ + Opcode: opcode, Payload: append([]byte(nil), payload...), + DeviceMicros: deviceMicros, Timed: timed, ObservedAt: time.Now(), ++ Generation: snapshot.Generation, + }) + return nil + } +@@ -733,6 +794,62 @@ func (runtime *Runtime) publishCommandEvidence(evidence CommandEvidence) { + } + } + ++<<<<<<< HEAD ++======= ++// ObserveMacroStatuses subscribes board-local capture and playback services ++// without competing for Runtime.Events. The callback receives a value copy in ++// UART order; the returned release function is idempotent. ++func (runtime *Runtime) ObserveMacroStatuses(observer func(native.MacroStatus, uint64)) func() { ++ if observer == nil { ++ return func() {} ++ } ++ runtime.macroStatusObserverMu.Lock() ++ if runtime.macroStatusObservers == nil { ++ runtime.macroStatusObservers = make(map[uint64]func(native.MacroStatus, uint64)) ++ } ++ runtime.nextMacroStatusObserver++ ++ id := runtime.nextMacroStatusObserver ++ runtime.macroStatusObservers[id] = observer ++ runtime.macroStatusObserverMu.Unlock() ++ var once sync.Once ++ return func() { ++ once.Do(func() { ++ runtime.macroStatusObserverMu.Lock() ++ delete(runtime.macroStatusObservers, id) ++ runtime.macroStatusObserverMu.Unlock() ++ }) ++ } ++} ++ ++func (runtime *Runtime) publishBoardAction(event native.DeviceEvent, generation uint64) { ++ if event.Type != native.EventAction { ++ return ++ } ++ runtime.publishActionEvidence(ActionEvidence{ ++ Opcode: event.ActionOpcode, Payload: append([]byte(nil), event.ActionPayload...), ++ Source: event.Source, SourceID: event.SourceID, BoardOrigin: true, ++ DeviceMicros: event.DeviceMicros, Timed: event.Timed, ObservedAt: time.Now(), ++ Generation: generation, ++ }) ++} ++ ++func (runtime *Runtime) publishMacroStatus(status native.MacroStatus, generation ...uint64) { ++ value := runtime.Snapshot().Generation ++ if len(generation) != 0 { ++ value = generation[0] ++ } ++ runtime.macroStatusObserverMu.RLock() ++ observers := make([]func(native.MacroStatus, uint64), 0, len(runtime.macroStatusObservers)) ++ for _, observer := range runtime.macroStatusObservers { ++ observers = append(observers, observer) ++ } ++ runtime.macroStatusObserverMu.RUnlock() ++ for _, observer := range observers { ++ observer(status, value) ++ } ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func (runtime *Runtime) RefreshFrontPanel(ctx context.Context) (native.FrontPanel, error) { + frame, err := runtime.Request(ctx, native.OpFrontPanelGet, nil, native.OpFrontPanel) + if err != nil { +@@ -1128,11 +1245,19 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { + for { + select { + case event := <-session.Events(): ++ // A replaced session may still have buffered frames when its read ++ // goroutine unwinds. Reject them before any cache, action, or macro ++ // observer can relabel the frame with the new connection generation. ++ if !runtime.sessionGenerationCurrent(session, generation) { ++ return ++ } + if event.Err != nil { + disconnectReason = event.Err.Error() + runtime.publish("error", event.Err.Error(), native.Frame{}) + } else { +- runtime.observe(event.Frame) ++ if !runtime.observeAtGeneration(event.Frame, generation) { ++ return ++ } + kind := "rx" + text := fmt.Sprintf( + "%s seq=%d payload=% X", +@@ -1163,6 +1288,13 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { + rfMappingRequired, rfCaptured = runtime.observeRFLearningEvent(parsed) + kind, text = describeDeviceEvent(parsed) + parsedDevice = &parsed ++<<<<<<< HEAD ++======= ++ runtime.publishBoardAction(parsed, generation) ++ if parsed.Macro != nil { ++ runtime.publishMacroStatus(*parsed.Macro, generation) ++ } ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } else { + kind = "error" + text = err.Error() +@@ -1276,6 +1408,16 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { + "page": parsedDevice.AppPage, "value": parsedDevice.AppPage, + "target_instance": parsedDevice.AppTarget, + } ++ } else if parsedDevice.Type == native.EventAction { ++ deviceEvent.Metadata = map[string]string{ ++ "connection_generation": strconv.FormatUint(generation, 10), ++ "device_micros": strconv.FormatUint(uint64(parsedDevice.DeviceMicros), 10), ++ "source": inputSourceName(parsedDevice.Source), ++ "source_id": strconv.Itoa(int(parsedDevice.SourceID)), ++ "opcode": fmt.Sprintf("0x%02X", parsedDevice.ActionOpcode), ++ "opcode_name": native.OpcodeName(parsedDevice.ActionOpcode), ++ "payload": fmt.Sprintf("%X", parsedDevice.ActionPayload), ++ } + } + runtime.publishEvent(deviceEvent) + } else { +@@ -1321,6 +1463,15 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { + } + } + ++func (runtime *Runtime) sessionGenerationCurrent( ++ session *link.Session, ++ generation uint64, ++) bool { ++ runtime.mu.RLock() ++ defer runtime.mu.RUnlock() ++ return runtime.session == session && runtime.generation == generation ++} ++ + func (runtime *Runtime) autoReconnect(epoch uint64) { + watchContext, stopWatching := context.WithCancel(context.Background()) + defer stopWatching() +@@ -1400,6 +1551,20 @@ func (runtime *Runtime) autoReconnect(epoch uint64) { + func (runtime *Runtime) observe(frame native.Frame) { + runtime.mu.Lock() + defer runtime.mu.Unlock() ++ runtime.observeLocked(frame) ++} ++ ++func (runtime *Runtime) observeAtGeneration(frame native.Frame, generation uint64) bool { ++ runtime.mu.Lock() ++ defer runtime.mu.Unlock() ++ if runtime.generation != generation || runtime.session == nil { ++ return false ++ } ++ runtime.observeLocked(frame) ++ return true ++} ++ ++func (runtime *Runtime) observeLocked(frame native.Frame) { + switch frame.Opcode { + case native.OpStatus: + if status, err := native.ParseStatus(frame.Payload); err == nil { +@@ -1512,6 +1677,12 @@ func describeDeviceEvent(event native.DeviceEvent) (string, string) { + native.MacroCancelled: "cancelled", + native.MacroCompleted: "completed", + native.MacroFailed: "failed", ++<<<<<<< HEAD ++======= ++ native.MacroRecording: "recording", ++ native.MacroCaptured: "captured", ++ native.MacroExported: "exported", ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + }[event.Macro.State] + if state == "" { + state = fmt.Sprintf("state-%d", event.Macro.State) +diff --git a/Tools/Controller/internal/control/runtime_test.go b/Tools/Controller/internal/control/runtime_test.go +index 2f461914..1407edb0 100644 +--- a/Tools/Controller/internal/control/runtime_test.go ++++ b/Tools/Controller/internal/control/runtime_test.go +@@ -17,6 +17,30 @@ type reconnectTestPort struct { + closed chan struct{} + } + ++func TestOldPumpFramesAreRejectedAfterSessionReplacement(t *testing.T) { ++ runtime := New(Options{}) ++ oldSession := &link.Session{} ++ newSession := &link.Session{} ++ runtime.mu.Lock() ++ runtime.session = oldSession ++ runtime.generation = 7 ++ runtime.mu.Unlock() ++ if !runtime.sessionGenerationCurrent(oldSession, 7) { ++ t.Fatal("current pump generation was rejected") ++ } ++ runtime.mu.Lock() ++ runtime.session = newSession ++ runtime.generation = 8 ++ runtime.mu.Unlock() ++ if runtime.sessionGenerationCurrent(oldSession, 7) || ++ runtime.sessionGenerationCurrent(oldSession, 8) { ++ t.Fatal("buffered old-session frame could enter replacement generation") ++ } ++ if !runtime.sessionGenerationCurrent(newSession, 8) { ++ t.Fatal("replacement session generation was rejected") ++ } ++} ++ + func newReconnectTestPort() *reconnectTestPort { + return &reconnectTestPort{closed: make(chan struct{})} + } +diff --git a/Tools/Controller/internal/ipcjson/wire_interop_test.go b/Tools/Controller/internal/ipcjson/wire_interop_test.go +index 2fdb6865..1b3a8517 100644 +--- a/Tools/Controller/internal/ipcjson/wire_interop_test.go ++++ b/Tools/Controller/internal/ipcjson/wire_interop_test.go +@@ -443,10 +443,12 @@ func (board *rawVirtualBoard) acceptLoop() { + switch request.Opcode { + case native.OpHello: + response.Opcode = native.OpHelloResp +- response.Payload = make([]byte, 14) ++ response.Payload = make([]byte, 16) + response.Payload[0] = native.IdentitySchemaCompact + response.Payload[1] = native.BoardKindPCController + binary.LittleEndian.PutUint32(response.Payload[6:10], 0xA1B2C3D4) ++ response.Payload[14] = native.FeatureProfileFullPeripheral ++ response.Payload[15] = native.BuildFeatureLocalMacroCapture + case native.OpGetStatus: + count := board.statusCount.Add(1) + response.Opcode = native.OpStatus +diff --git a/Tools/Controller/internal/link/session_test.go b/Tools/Controller/internal/link/session_test.go +index c1abc0c3..4b154674 100644 +--- a/Tools/Controller/internal/link/session_test.go ++++ b/Tools/Controller/internal/link/session_test.go +@@ -15,12 +15,14 @@ import ( + ) + + func currentHelloPayload(capabilities uint32) []byte { +- payload := make([]byte, 14) ++ payload := make([]byte, 16) + payload[0] = native.IdentitySchemaCompact + payload[1] = native.BoardKindPCController + binary.LittleEndian.PutUint32(payload[2:6], capabilities) + binary.LittleEndian.PutUint32(payload[6:10], 0x2FD9F81C) + binary.LittleEndian.PutUint32(payload[10:14], 0x35019D5D) ++ payload[14] = native.FeatureProfileFullPeripheral ++ payload[15] = native.BuildFeatureLocalMacroCapture + return payload + } + +@@ -117,10 +119,11 @@ func TestAuthenticateRequiresPCControllerIdentity(t *testing.T) { + } + } + +-func TestAuthenticateAcceptsCompactHelloSchema3(t *testing.T) { ++func TestAuthenticateAcceptsCompactHelloSchema4(t *testing.T) { + payload := []byte{ +- 0x03, native.BoardKindPCController, 0x00, 0x00, 0x00, 0x00, ++ 0x04, native.BoardKindPCController, 0x00, 0x00, 0x00, 0x00, + 0x1C, 0xF8, 0xD9, 0x2F, 0x5D, 0x9D, 0x01, 0x35, ++ native.FeatureProfileFullPeripheral, native.BuildFeatureLocalMacroCapture, + } + port := newFakePort() + port.onWrite = func(encoded []byte) { +diff --git a/Tools/Controller/internal/native/cmd/genmacrocontract/main.go b/Tools/Controller/internal/native/cmd/genmacrocontract/main.go +new file mode 100644 +index 00000000..d17d59e0 +--- /dev/null ++++ b/Tools/Controller/internal/native/cmd/genmacrocontract/main.go +@@ -0,0 +1,210 @@ ++// Command genmacrocontract compiles the canonical C++ protocol/action tables ++// into the Go host lookup. The generated source is intentionally numeric so ++// naming style differences cannot become a second handwritten mapping. ++package main ++ ++import ( ++ "bytes" ++ "crypto/sha256" ++ "flag" ++ "fmt" ++ "go/format" ++ "os" ++ "regexp" ++ "sort" ++ "strconv" ++ "strings" ++) ++ ++var ( ++ enumRow = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9_]*)\s*=\s*(0x[0-9A-Fa-f]+|[0-9]+)\s*,`) ++ actionRow = regexp.MustCompile(`(?m)^\s*PCCONTROLLER_MACRO_ACTION\(\s*([A-Za-z][A-Za-z0-9_]*)\s*,\s*(0x[0-9A-Fa-f]+|[0-9]+)\s*\)`) ++ enumBlock = regexp.MustCompile(`(?s)enum\s+([A-Za-z][A-Za-z0-9_]*)\s*:\s*uint8_t\s*\{(.*?)\};`) ++ valueRow = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9_]*)\s*=\s*([^,]+),`) ++ shiftExpr = regexp.MustCompile(`^1U?\s*<<\s*([0-7])$`) ++) ++ ++type action struct { ++ name string ++ opcode uint8 ++ captureLength uint8 ++} ++ ++type namedValue struct { ++ name string ++ value uint8 ++} ++ ++func main() { ++ protocolPath := flag.String("protocol", "", "path to Project/ProtocolContract.h") ++ actionsPath := flag.String("actions", "", "path to canonical MacroActions data") ++ outputPath := flag.String("output", "", "generated Go output") ++ flag.Parse() ++ if *protocolPath == "" || *actionsPath == "" || *outputPath == "" { ++ fatalf("-protocol, -actions, and -output are required") ++ } ++ protocol, err := os.ReadFile(*protocolPath) ++ if err != nil { ++ fatalf("read protocol: %v", err) ++ } ++ actionsSource, err := os.ReadFile(*actionsPath) ++ if err != nil { ++ fatalf("read actions: %v", err) ++ } ++ opcodes, err := parseOpcodes(protocol) ++ if err != nil { ++ fatalf("parse protocol: %v", err) ++ } ++ actions, err := parseActions(actionsSource, opcodes) ++ if err != nil { ++ fatalf("parse actions: %v", err) ++ } ++ profiles, err := parseUint8Enum(protocol, "FeatureProfile") ++ if err != nil { ++ fatalf("parse feature profiles: %v", err) ++ } ++ features, err := parseUint8Enum(protocol, "BuildFeatureFlag") ++ if err != nil { ++ fatalf("parse build feature flags: %v", err) ++ } ++ hash := sha256.New() ++ _, _ = hash.Write(protocol) ++ _, _ = hash.Write([]byte{0}) ++ _, _ = hash.Write(actionsSource) ++ sourceHash := fmt.Sprintf("%x", hash.Sum(nil)) ++ ++ var generated bytes.Buffer ++ fmt.Fprintln(&generated, "// Code generated by go generate; DO NOT EDIT.") ++ fmt.Fprintln(&generated) ++ fmt.Fprintln(&generated, "package native") ++ fmt.Fprintln(&generated) ++ fmt.Fprintf(&generated, "const macroContractSourceSHA256 = %q\n\n", sourceHash) ++ fmt.Fprintln(&generated, "const (") ++ for _, row := range profiles { ++ fmt.Fprintf(&generated, "\t%s byte = 0x%02X\n", goFeatureProfileName(row.name), row.value) ++ } ++ fmt.Fprintln(&generated, ")") ++ fmt.Fprintln(&generated) ++ fmt.Fprintln(&generated, "const (") ++ for _, row := range features { ++ fmt.Fprintf(&generated, "\tBuildFeature%s byte = 0x%02X\n", row.name, row.value) ++ } ++ fmt.Fprintln(&generated, ")") ++ fmt.Fprintln(&generated) ++ fmt.Fprintln(&generated, "var macroActionContracts = map[byte]macroActionContract{") ++ for _, row := range actions { ++ fmt.Fprintf( ++ &generated, ++ "\t0x%02X: {captureLength: 0x%02X}, // %s\n", ++ row.opcode, ++ row.captureLength, ++ row.name, ++ ) ++ } ++ fmt.Fprintln(&generated, "}") ++ formatted, err := format.Source(generated.Bytes()) ++ if err != nil { ++ fatalf("format generated source: %v\n%s", err, generated.String()) ++ } ++ if err := os.WriteFile(*outputPath, formatted, 0o644); err != nil { ++ fatalf("write output: %v", err) ++ } ++} ++ ++func parseUint8Enum(source []byte, name string) ([]namedValue, error) { ++ for _, match := range enumBlock.FindAllSubmatch(source, -1) { ++ if string(match[1]) != name { ++ continue ++ } ++ rows := valueRow.FindAllSubmatch(match[2], -1) ++ result := make([]namedValue, 0, len(rows)) ++ seen := make(map[uint8]string) ++ for _, row := range rows { ++ entry := string(row[1]) ++ value, err := parseUint8Expression(string(row[2])) ++ if err != nil { ++ return nil, fmt.Errorf("%s %s: %w", name, entry, err) ++ } ++ if previous, exists := seen[value]; exists { ++ return nil, fmt.Errorf("%s entries %s and %s share 0x%02X", name, previous, entry, value) ++ } ++ seen[value] = entry ++ result = append(result, namedValue{name: entry, value: value}) ++ } ++ if len(result) == 0 { ++ return nil, fmt.Errorf("enum %s has no explicit rows", name) ++ } ++ return result, nil ++ } ++ return nil, fmt.Errorf("enum %s was not found", name) ++} ++ ++func parseUint8Expression(expression string) (uint8, error) { ++ expression = strings.TrimSpace(expression) ++ if shift := shiftExpr.FindStringSubmatch(expression); shift != nil { ++ bit, _ := strconv.ParseUint(shift[1], 10, 8) ++ return uint8(1 << bit), nil ++ } ++ value, err := strconv.ParseUint(strings.TrimSuffix(expression, "U"), 0, 8) ++ if err != nil { ++ return 0, fmt.Errorf("unsupported uint8 expression %q", expression) ++ } ++ return uint8(value), nil ++} ++ ++func goFeatureProfileName(name string) string { ++ if strings.HasSuffix(name, "FeatureProfile") { ++ return "FeatureProfile" + strings.TrimSuffix(name, "FeatureProfile") ++ } ++ return "FeatureProfile" + strings.TrimSuffix(name, "Profile") ++} ++ ++func parseOpcodes(source []byte) (map[string]uint8, error) { ++ result := make(map[string]uint8) ++ for _, match := range enumRow.FindAllSubmatch(source, -1) { ++ value, err := strconv.ParseUint(string(match[2]), 0, 8) ++ if err != nil { ++ return nil, fmt.Errorf("%s value %q: %w", match[1], match[2], err) ++ } ++ name := string(match[1]) ++ if previous, exists := result[name]; exists && previous != uint8(value) { ++ return nil, fmt.Errorf("duplicate opcode name %s", name) ++ } ++ result[name] = uint8(value) ++ } ++ if len(result) == 0 { ++ return nil, fmt.Errorf("no opcode rows found") ++ } ++ return result, nil ++} ++ ++func parseActions(source []byte, opcodes map[string]uint8) ([]action, error) { ++ var result []action ++ seenOpcode := make(map[uint8]string) ++ for _, match := range actionRow.FindAllSubmatch(source, -1) { ++ name := string(match[1]) ++ opcode, exists := opcodes[name] ++ if !exists { ++ return nil, fmt.Errorf("action %s has no canonical opcode", name) ++ } ++ value, err := strconv.ParseUint(string(match[2]), 0, 8) ++ if err != nil { ++ return nil, fmt.Errorf("%s capture length %q: %w", name, match[2], err) ++ } ++ if previous, exists := seenOpcode[opcode]; exists { ++ return nil, fmt.Errorf("actions %s and %s share opcode 0x%02X", previous, name, opcode) ++ } ++ seenOpcode[opcode] = name ++ result = append(result, action{name: name, opcode: opcode, captureLength: uint8(value)}) ++ } ++ if len(result) == 0 { ++ return nil, fmt.Errorf("no PCCONTROLLER_MACRO_ACTION rows found in %s", strings.TrimSpace(string(source))) ++ } ++ sort.Slice(result, func(i, j int) bool { return result[i].opcode < result[j].opcode }) ++ return result, nil ++} ++ ++func fatalf(format string, args ...any) { ++ fmt.Fprintf(os.Stderr, "genmacrocontract: "+format+"\n", args...) ++ os.Exit(1) ++} +diff --git a/Tools/Controller/internal/native/frame_test.go b/Tools/Controller/internal/native/frame_test.go +index 33b5a3d0..83c9efd6 100644 +--- a/Tools/Controller/internal/native/frame_test.go ++++ b/Tools/Controller/internal/native/frame_test.go +@@ -212,10 +212,10 @@ func TestPayloadBuildersValidateRanges(t *testing.T) { + } + } + +-func TestParseHelloCompactIdentitySchema3(t *testing.T) { ++func TestParseHelloCompactIdentitySchema4(t *testing.T) { + payload := []byte{ +- 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x1C, +- 0xF8, 0xD9, 0x2F, 0x5D, 0x9D, 0x01, 0x35, ++ 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x1C, ++ 0xF8, 0xD9, 0x2F, 0x5D, 0x9D, 0x01, 0x35, 0x00, 0xF9, + } + hello, err := ParseHello(payload) + if err != nil { +@@ -224,17 +224,23 @@ func TestParseHelloCompactIdentitySchema3(t *testing.T) { + if !hello.IsPCController() || hello.IdentitySchema != IdentitySchemaCompact || + hello.BoardKind != BoardKindPCController || hello.Name != "PCController" || + hello.Capabilities != 0 || hello.BuildHash != 0x2FD9F81C || +- hello.BuildTimestamp != 0x35019D5D || hello.BuildStamp != "260801194258" { ++ hello.BuildTimestamp != 0x35019D5D || hello.BuildStamp != "260801194258" || ++ hello.FeatureProfile != FeatureProfileFullPeripheral || hello.BuildFeatures != 0xF9 { + t.Fatalf("unexpected compact HELLO: %#v", hello) + } +- if _, err := ParseHello(payload[:13]); err == nil { ++ if _, err := ParseHello(payload[:15]); err == nil { + t.Fatal("truncated compact HELLO was accepted") + } + wrongSchema := append([]byte(nil), payload...) +- wrongSchema[0] = 2 ++ wrongSchema[0] = 3 + if _, err := ParseHello(wrongSchema); err == nil { + t.Fatal("unpublished expanded HELLO schema was accepted") + } ++ unknownProfile := append([]byte(nil), payload...) ++ unknownProfile[14] = 4 ++ if _, err := ParseHello(unknownProfile); err == nil { ++ t.Fatal("unknown firmware feature profile was accepted") ++ } + invalidTimestamp := append([]byte(nil), payload...) + binary.LittleEndian.PutUint32(invalidTimestamp[10:14], 0x341F0000) + if _, err := ParseHello(invalidTimestamp); err == nil { +diff --git a/Tools/Controller/internal/native/macro_contract_generated.go b/Tools/Controller/internal/native/macro_contract_generated.go +new file mode 100644 +index 00000000..e6370498 +--- /dev/null ++++ b/Tools/Controller/internal/native/macro_contract_generated.go +@@ -0,0 +1,40 @@ ++// Code generated by go generate; DO NOT EDIT. ++ ++package native ++ ++const macroContractSourceSHA256 = "9ab1a849e2b2a615888aea70f4b89f84758367bcca9bb1615e4b225f53965dfc" ++ ++const ( ++ FeatureProfileFullPeripheral byte = 0x00 ++ FeatureProfileMotionMacro byte = 0x01 ++ FeatureProfileKeyDiagnostic byte = 0x02 ++ FeatureProfileCustom byte = 0x03 ++) ++ ++const ( ++ BuildFeatureLocalMacroCapture byte = 0x01 ++ BuildFeatureUnifiedPageIdentifiesKeys byte = 0x02 ++ BuildFeatureForceSilent byte = 0x04 ++ BuildFeatureBlankEepromSilent byte = 0x08 ++ BuildFeatureMcuLcdRenderer byte = 0x10 ++ BuildFeatureLocalPcaPages byte = 0x20 ++ BuildFeatureStatusLedEngine byte = 0x40 ++ BuildFeatureIlluminationAutomation byte = 0x80 ++) ++ ++var macroActionContracts = map[byte]macroActionContract{ ++ 0x10: {captureLength: 0x04}, // Buzzer ++ 0x11: {captureLength: 0x03}, // PwmSet ++ 0x12: {captureLength: 0x00}, // PwmAllOff ++ 0x14: {captureLength: 0x04}, // StatusRgb ++ 0x16: {captureLength: 0x05}, // AddressableLed ++ 0x17: {captureLength: 0xFF}, // StatusEffect ++ 0x20: {captureLength: 0x08}, // RadioTransmit ++ 0x30: {captureLength: 0x01}, // MenuAction ++ 0x31: {captureLength: 0x02}, // RelaySet ++ 0x32: {captureLength: 0x02}, // RelaySide ++ 0x33: {captureLength: 0x00}, // RelayAllOff ++ 0x37: {captureLength: 0x01}, // MenuSetPage ++ 0x38: {captureLength: 0xFF}, // DisplayText ++ 0x3D: {captureLength: 0x02}, // RemoteKeyGesture ++} +diff --git a/Tools/Controller/internal/native/macro_queue.go b/Tools/Controller/internal/native/macro_queue.go +index fb93c6d6..32c91074 100644 +--- a/Tools/Controller/internal/native/macro_queue.go ++++ b/Tools/Controller/internal/native/macro_queue.go +@@ -1,29 +1,51 @@ + package native + ++//go:generate go run ./cmd/genmacrocontract -protocol ../../../../Project/ProtocolContract.h -actions ../../../../Project/MacroActions.inc.h -output macro_contract_generated.go ++ + import ( + "encoding/binary" + "fmt" + ) + + const ( ++<<<<<<< HEAD + MacroQueueSchema byte = 2 + MacroExecutionSequence byte = 0xFE + MacroQueueCapacity = 127 + MacroAppendHeaderSize = 5 + MacroRecordHeaderSize = 6 + MacroMaximumFragment = MaxPayload - MacroAppendHeaderSize ++======= ++ MacroQueueSchema byte = 3 ++ MacroExecutionSequence byte = 0xFE ++ MacroQueueCapacity = 127 ++ MacroAppendHeaderSize = 5 ++ MacroRecordHeaderSize = 6 ++ MacroMaximumFragment = MaxPayload - MacroAppendHeaderSize ++ MacroCaptureChunkMaximum = 40 ++ // EventAction is intentionally smaller than an ordinary host command. It ++ // covers every board-generated physical/RF action without consuming a ++ // second MaximumPayload-sized AVR buffer. ++ MacroBoardActionMaximumPayload = 8 ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + ) + + const ( + MacroIdle byte = iota + MacroBuffering + MacroPlaying +- MacroCancelled + MacroCompleted ++ MacroCancelled + MacroFailed ++<<<<<<< HEAD ++======= ++ MacroRecording ++ MacroCaptured ++ MacroExported ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + ) + +-// MacroStatus is the common schema-2 envelope returned by MACRO_STATUS and ++// MacroStatus is the common schema-3 envelope returned by MACRO_STATUS and + // emitted for lifecycle changes. Free bytes are derived from Fill. + type MacroStatus struct { + Schema byte `json:"schema"` +@@ -105,6 +127,73 @@ func MacroQueueAppendPayload(offset, completeSteps uint16, fragment []byte) ([]b + func MacroQueueRunPayload() []byte { return []byte{1} } + func MacroQueueQueryPayload() []byte { return []byte{2} } + ++<<<<<<< HEAD ++======= ++// MacroCaptureQueryPayload requests the retained schema-3 board-capture ring ++// from an absolute byte offset. It does not alter capture/playback state. ++func MacroCaptureQueryPayload(id byte, offset uint16) []byte { ++ return []byte{3, id, byte(offset), byte(offset >> 8)} ++} ++ ++// MacroCaptureAcknowledgePayload marks one identity-guarded retained capture ++// durably exported. Firmware keeps the ring replayable/fetchable; only the ++// explicit user-only command 5 may clear it. ++func MacroCaptureAcknowledgePayload(id byte, startedAtUS uint32) []byte { ++ return []byte{ ++ 4, id, ++ byte(startedAtUS), byte(startedAtUS >> 8), ++ byte(startedAtUS >> 16), byte(startedAtUS >> 24), ++ } ++} ++ ++// MacroCaptureChunk is a bounded recovery page from the board-owned capture ++// ring. Data contains whole/partial stream bytes; callers concatenate pages ++// and validate complete records only after TotalBytes have arrived. ++type MacroCaptureChunk struct { ++ Schema byte `json:"schema"` ++ Command byte `json:"command"` ++ ID byte `json:"id"` ++ TotalBytes uint16 `json:"total_bytes"` ++ Offset uint16 `json:"offset"` ++ Data []byte `json:"data"` ++} ++ ++func ParseMacroCaptureChunk(payload []byte) (MacroCaptureChunk, error) { ++ const header = 8 ++ if len(payload) < header || payload[0] != MacroQueueSchema || payload[1] != 3 { ++ return MacroCaptureChunk{}, fmt.Errorf("invalid schema-3 macro capture chunk") ++ } ++ length := int(payload[7]) ++ if length > MaxPayload-header || len(payload) != header+length { ++ return MacroCaptureChunk{}, fmt.Errorf( ++ "macro capture chunk length %d/body %d is invalid", length, len(payload), ++ ) ++ } ++ chunk := MacroCaptureChunk{ ++ Schema: payload[0], Command: payload[1], ID: payload[2], ++ TotalBytes: binary.LittleEndian.Uint16(payload[3:5]), ++ Offset: binary.LittleEndian.Uint16(payload[5:7]), ++ Data: append([]byte(nil), payload[8:]...), ++ } ++ if chunk.TotalBytes > MacroQueueCapacity || len(chunk.Data) > MacroCaptureChunkMaximum { ++ return MacroCaptureChunk{}, fmt.Errorf( ++ "macro capture exceeds ring/page bounds: total=%d page=%d", ++ chunk.TotalBytes, len(chunk.Data), ++ ) ++ } ++ if chunk.Offset > chunk.TotalBytes || ++ int(chunk.Offset)+len(chunk.Data) > int(chunk.TotalBytes) { ++ return MacroCaptureChunk{}, fmt.Errorf( ++ "macro capture range %d+%d exceeds %d", chunk.Offset, len(chunk.Data), chunk.TotalBytes, ++ ) ++ } ++ if chunk.Offset < chunk.TotalBytes && len(chunk.Data) == 0 { ++ return MacroCaptureChunk{}, errors.New("macro capture returned an empty non-terminal chunk") ++ } ++ return chunk, nil ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + func MacroQueueCancelPayload(keepOutputs bool) []byte { + if keepOutputs { + return []byte{1} +@@ -117,8 +206,13 @@ func MacroQueueCancelPayload(keepOutputs bool) []byte { + // EncodeMacroRecord stores one ordinary opcode command against an MCU-clock + // due offset. Macro-control recursion is rejected before it reaches firmware. + func EncodeMacroRecord(dueUS uint32, opcode byte, payload []byte) ([]byte, error) { ++<<<<<<< HEAD + if opcode >= OpMacroStart && opcode <= OpMacroStep { + return nil, fmt.Errorf("macro control opcode 0x%02X cannot be queued recursively", opcode) ++======= ++ if !MacroPlaybackAllowed(opcode) { ++ return nil, fmt.Errorf("opcode 0x%02X is not a queueable acknowledged command", opcode) ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + if len(payload) > MaxPayload { + return nil, ErrPayloadTooLong +@@ -131,6 +225,31 @@ func EncodeMacroRecord(dueUS uint32, opcode byte, payload []byte) ([]byte, error + return record, nil + } + ++<<<<<<< HEAD ++======= ++type macroActionContract struct { ++ captureLength byte ++} ++ ++// MacroPlaybackAllowed is generated from Project/MacroActions.inc.h and is the ++// exact safe ordinary-action allowlist enforced by the production AVR queue. ++func MacroPlaybackAllowed(opcode byte) bool { ++ _, exists := macroActionContracts[opcode] ++ return exists ++} ++ ++// MacroBoardActionPayloadLength returns the exact fixed evidence prefix. A ++// 0xFF canonical row is playback-only/variable and cannot enter board capture. ++func MacroBoardActionPayloadLength(opcode byte) (byte, bool) { ++ contract, exists := macroActionContracts[opcode] ++ if !exists || contract.captureLength == 0xFF || ++ contract.captureLength > MacroBoardActionMaximumPayload { ++ return 0, false ++ } ++ return contract.captureLength, true ++} ++ ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + // ResponseDeviceMicros extracts the schema-2 MCU timestamp appended to ACKs + // and reserved-sequence execution errors. + func ResponseDeviceMicros(frame Frame) (uint32, bool) { +diff --git a/Tools/Controller/internal/native/macro_queue_test.go b/Tools/Controller/internal/native/macro_queue_test.go +index efed39db..2fded593 100644 +--- a/Tools/Controller/internal/native/macro_queue_test.go ++++ b/Tools/Controller/internal/native/macro_queue_test.go +@@ -1,11 +1,61 @@ + package native + + import ( ++ "crypto/sha256" + "encoding/binary" ++ "fmt" ++ "os" ++ "path/filepath" ++ "runtime" + "testing" + ) + +-func TestMacroStatusSchemaTwoRoundTripLayout(t *testing.T) { ++func TestGeneratedMacroContractMatchesCanonicalCXXSources(t *testing.T) { ++ _, filename, _, ok := runtime.Caller(0) ++ if !ok { ++ t.Fatal("could not locate macro contract test") ++ } ++ root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..", "..")) ++ protocol, err := os.ReadFile(filepath.Join(root, "Project", "ProtocolContract.h")) ++ if err != nil { ++ t.Fatal(err) ++ } ++ actions, err := os.ReadFile(filepath.Join(root, "Project", "MacroActions.inc.h")) ++ if err != nil { ++ t.Fatal(err) ++ } ++ hash := sha256.New() ++ _, _ = hash.Write(protocol) ++ _, _ = hash.Write([]byte{0}) ++ _, _ = hash.Write(actions) ++ if got := fmt.Sprintf("%x", hash.Sum(nil)); got != macroContractSourceSHA256 { ++ t.Fatalf("generated macro contract is stale: got source %s, generated %s; run go generate ./internal/native", got, macroContractSourceSHA256) ++ } ++ if FeatureProfileFullPeripheral != 0 || FeatureProfileMotionMacro != 1 || ++ FeatureProfileKeyDiagnostic != 2 || FeatureProfileCustom != 3 { ++ t.Fatal("generated feature-profile values differ from ProtocolContract.h") ++ } ++ if BuildFeatureLocalMacroCapture != 1 || ++ BuildFeatureUnifiedPageIdentifiesKeys != 2 || ++ BuildFeatureForceSilent != 4 || BuildFeatureBlankEepromSilent != 8 || ++ BuildFeatureMcuLcdRenderer != 16 || BuildFeatureLocalPcaPages != 32 || ++ BuildFeatureStatusLedEngine != 64 || ++ BuildFeatureIlluminationAutomation != 128 { ++ t.Fatal("generated build-feature values differ from ProtocolContract.h") ++ } ++ if !MacroPlaybackAllowed(OpRelaySide) || MacroPlaybackAllowed(OpSetSettings) || ++ MacroPlaybackAllowed(OpRFLearnStart) || MacroPlaybackAllowed(OpRelayTest) { ++ t.Fatal("generated playback allowlist differs from the AVR safety registry") ++ } ++ if length, recordable := MacroBoardActionPayloadLength(OpRelayAllOff); !recordable || length != 0 { ++ t.Fatalf("zero-payload capture contract=%d/%t", length, recordable) ++ } ++ if _, recordable := MacroBoardActionPayloadLength(OpStatusEffect); recordable { ++ t.Fatal("variable playback-only action entered board evidence") ++ } ++} ++ ++func TestMacroStatusSchemaThreeRoundTripLayout(t *testing.T) { + payload := []byte{ + EventMacro, MacroQueueSchema, MacroPlaying, 9, + 7, 0, 5, 0, 64, 0, +@@ -71,3 +121,67 @@ func TestMacroFragmentsCarryAbsoluteStreamAndCompleteStepOffsets(t *testing.T) { + t.Fatalf("unexpected APPEND payload: %v", payload) + } + } ++<<<<<<< HEAD ++======= ++ ++func TestBoardActionEventUsesOrdinaryMacroOpcodeContract(t *testing.T) { ++ payload := []byte{ ++ EventAction | 0x80, InputSourcePhysical, OpRelaySide, 2, 1, 2, ++ 0x78, 0x56, 0x34, 0x12, ++ } ++ event, err := ParseDeviceEvent(payload) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if event.Type != EventAction || !event.Timed || ++ event.DeviceMicros != 0x12345678 || event.Source != InputSourcePhysical || ++ event.ActionOpcode != OpRelaySide || len(event.ActionPayload) != 2 || ++ event.ActionPayload[0] != 1 || event.ActionPayload[1] != 2 { ++ t.Fatalf("unexpected action event: %#v", event) ++ } ++} ++ ++func TestBoardActionEventRejectsControlAndOversizedPayload(t *testing.T) { ++ for _, payload := range [][]byte{ ++ {EventAction, InputSourcePhysical, OpMacroStart, 0}, ++ {EventAction, InputSourcePhysical, OpRelayAllOff, 1, 0}, ++ {EventAction, InputSourceRF, OpRelaySet, 1, 0}, ++ {EventAction, InputSourceRF, OpRelaySet, MacroBoardActionMaximumPayload + 1, ++ 0, 0, 0, 0, 0, 0, 0, 0, 0}, ++ } { ++ if _, err := ParseDeviceEvent(payload); err == nil { ++ t.Fatalf("invalid action event accepted: % X", payload) ++ } ++ } ++} ++ ++func TestBoardCaptureCommandsCarryIdentity(t *testing.T) { ++ query := MacroCaptureQueryPayload(0x2A, 0x1234) ++ if len(query) != 4 || query[0] != 3 || query[1] != 0x2A || ++ binary.LittleEndian.Uint16(query[2:]) != 0x1234 { ++ t.Fatalf("capture query payload=% X", query) ++ } ++ ack := MacroCaptureAcknowledgePayload(0x2A, 0x78563412) ++ if len(ack) != 6 || ack[0] != 4 || ack[1] != 0x2A || ++ binary.LittleEndian.Uint32(ack[2:]) != 0x78563412 { ++ t.Fatalf("capture acknowledgement payload=% X", ack) ++ } ++} ++ ++func TestMacroCaptureChunkUsesBoundedOffsetPages(t *testing.T) { ++ payload := []byte{MacroQueueSchema, 3, 9, 5, 0, 2, 0, 3, 0xAA, 0xBB, 0xCC} ++ chunk, err := ParseMacroCaptureChunk(payload) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if chunk.ID != 9 || chunk.TotalBytes != 5 || chunk.Offset != 2 || ++ len(chunk.Data) != 3 || chunk.Data[2] != 0xCC { ++ t.Fatalf("unexpected capture chunk: %#v", chunk) ++ } ++ query := MacroCaptureQueryPayload(9, 0x1234) ++ if len(query) != 4 || query[0] != 3 || query[1] != 9 || ++ query[2] != 0x34 || query[3] != 0x12 { ++ t.Fatalf("unexpected capture query: % X", query) ++ } ++} ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) +diff --git a/Tools/Controller/internal/native/responses.go b/Tools/Controller/internal/native/responses.go +index a4afbb9b..5d8d5c1e 100644 +--- a/Tools/Controller/internal/native/responses.go ++++ b/Tools/Controller/internal/native/responses.go +@@ -12,7 +12,7 @@ import ( + const ( + BoardKindPCController byte = 1 + SettingsShape byte = 3 +- IdentitySchemaCompact byte = 3 ++ IdentitySchemaCompact byte = 4 + RFEntriesSchema byte = 1 + MenuListSchema byte = 1 + TemperatureSchema byte = 1 +@@ -43,6 +43,15 @@ const ( + + const MaximumBoardNameLength = 8 + ++func FeatureProfileName(profile byte) string { ++ return map[byte]string{ ++ FeatureProfileFullPeripheral: "full-peripheral", ++ FeatureProfileMotionMacro: "motion-macro", ++ FeatureProfileKeyDiagnostic: "key-diagnostic", ++ FeatureProfileCustom: "custom", ++ }[profile] ++} ++ + type BoardName struct { + Name string `json:"name"` + Persisted bool `json:"persisted"` +@@ -327,11 +336,13 @@ type Hello struct { + BuildHash uint32 `json:"build_hash"` + BuildTimestamp uint32 `json:"build_timestamp_packed,omitempty"` + BuildStamp string `json:"build_timestamp,omitempty"` ++ FeatureProfile byte `json:"feature_profile"` ++ BuildFeatures byte `json:"build_features"` + } + + func ParseHello(payload []byte) (Hello, error) { +- if len(payload) != 14 { +- return Hello{}, fmt.Errorf("HELLO payload is %d bytes, need exactly 14", len(payload)) ++ if len(payload) != 16 { ++ return Hello{}, fmt.Errorf("HELLO payload is %d bytes, need exactly 16", len(payload)) + } + if payload[0] != IdentitySchemaCompact { + return Hello{}, fmt.Errorf("unsupported HELLO identity schema %d", payload[0]) +@@ -343,6 +354,11 @@ func ParseHello(payload []byte) (Hello, error) { + IdentitySchema: IdentitySchemaCompact, + BuildHash: binary.LittleEndian.Uint32(payload[6:10]), + BuildTimestamp: binary.LittleEndian.Uint32(payload[10:14]), ++ FeatureProfile: payload[14], ++ BuildFeatures: payload[15], ++ } ++ if FeatureProfileName(hello.FeatureProfile) == "" { ++ return Hello{}, fmt.Errorf("unsupported firmware feature profile %d", hello.FeatureProfile) + } + stamp, err := FormatBuildTimestamp(hello.BuildTimestamp) + if err != nil { +@@ -795,6 +811,32 @@ func ParseDeviceEvent(payload []byte) (DeviceEvent, error) { + AppNavigationAll: "*", AppNavigationWebUI: "webui", AppNavigationTUI: "tui", + }[payload[1]] + event.AppPage = strings.ToLower(page) ++<<<<<<< HEAD ++======= ++ case EventAction: ++ // [type, source, ordinary opcode, payload length, payload...]. The ++ // high-bit event marker and trailing MCU timestamp are removed above. ++ if len(payload) < 4 { ++ return DeviceEvent{}, fmt.Errorf("action EVENT is %d bytes, need at least 4", len(payload)) ++ } ++ if payload[1] > InputSourceHost { ++ return DeviceEvent{}, fmt.Errorf("action EVENT source %d is invalid", payload[1]) ++ } ++ length := int(payload[3]) ++ if length > MacroBoardActionMaximumPayload || len(payload) != 4+length { ++ return DeviceEvent{}, fmt.Errorf( ++ "action EVENT payload length %d/body %d is invalid; maximum is %d", ++ length, len(payload), MacroBoardActionMaximumPayload, ++ ) ++ } ++ required, recordable := MacroBoardActionPayloadLength(payload[2]) ++ if !recordable || byte(length) != required { ++ return DeviceEvent{}, fmt.Errorf("action EVENT opcode 0x%02X is not recordable", payload[2]) ++ } ++ event.Source = payload[1] ++ event.ActionOpcode = payload[2] ++ event.ActionPayload = append([]byte(nil), payload[4:]...) ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + return event, nil + } +diff --git a/Tools/Controller/internal/programmer/compile_manifest.go b/Tools/Controller/internal/programmer/compile_manifest.go +index 1c100626..f4d72493 100644 +--- a/Tools/Controller/internal/programmer/compile_manifest.go ++++ b/Tools/Controller/internal/programmer/compile_manifest.go +@@ -11,6 +11,8 @@ import ( + "sort" + "strings" + "time" ++ ++ "pccontroller.local/controller/internal/native" + ) + + const ( +@@ -25,11 +27,21 @@ type compileManifest struct { + GeneratedUTC time.Time `json:"generatedUtc"` + Target compileManifestTarget `json:"target"` + Source compileManifestSource `json:"source"` ++ EEPROMLayout compileManifestEEPROMLayout `json:"eepromLayout"` + StackBudget compileManifestStackBudget `json:"stackBudget"` + PatchRegions []compileManifestPatchRegion `json:"patchRegions"` + Artifacts []compileManifestArtifact `json:"artifacts"` + } + ++type compileManifestEEPROMLayout struct { ++ Schema byte `json:"schema"` ++ SettingsAddress uint32 `json:"settingsAddress"` ++ ControllerBytes uint32 `json:"controllerBytes"` ++ BoardNameBytes uint32 `json:"boardNameBytes"` ++ RecordBytes uint32 `json:"recordBytes"` ++ Checksum string `json:"checksum"` ++} ++ + type compileManifestPatchRegion struct { + Name string `json:"name"` + Start uint32 `json:"start"` +@@ -173,6 +185,12 @@ func writeCompileManifest( + PackedTimestamp: fmt.Sprintf("%08X", identity.PackedTimestamp), + BuildTimestamp: buildTimestamp, + }, ++ EEPROMLayout: compileManifestEEPROMLayout{ ++ Schema: EEPROMSettingsRecordSchema, SettingsAddress: EEPROMSettingsAddress, ++ ControllerBytes: EEPROMSettingsControllerBytes, ++ BoardNameBytes: 1 + native.MaximumBoardNameLength, ++ RecordBytes: EEPROMSettingsRecordBytes, Checksum: "CRC-8/ATM (poly 0x07)", ++ }, + StackBudget: stackBudget, + PatchRegions: firmwareIdentityManifestRegions(), + Artifacts: artifacts, +diff --git a/Tools/Controller/internal/programmer/compile_manifest_test.go b/Tools/Controller/internal/programmer/compile_manifest_test.go +index dc0763f7..4c64086a 100644 +--- a/Tools/Controller/internal/programmer/compile_manifest_test.go ++++ b/Tools/Controller/internal/programmer/compile_manifest_test.go +@@ -62,6 +62,8 @@ func TestCompileManifestAtomicallyReplacesStaleMetadataFromActualArtifacts(t *te + manifest.Source.PackedTimestamp != "35019D5D" || + manifest.Source.BuildTimestamp != "260801194258" || + manifest.StackBudget.EstimatedFreeSRAMBytes != 408 || ++ manifest.EEPROMLayout.Schema != EEPROMSettingsRecordSchema || ++ manifest.EEPROMLayout.ControllerBytes != 22 || manifest.EEPROMLayout.RecordBytes != 32 || + len(manifest.Artifacts) != 4 || len(manifest.PatchRegions) != 1 { + t.Fatalf("manifest=%#v", manifest) + } +diff --git a/Tools/Controller/internal/programmer/default_eeprom.go b/Tools/Controller/internal/programmer/default_eeprom.go +index 43213fc9..829ee464 100644 +--- a/Tools/Controller/internal/programmer/default_eeprom.go ++++ b/Tools/Controller/internal/programmer/default_eeprom.go +@@ -7,6 +7,7 @@ import ( + "io" + "os" + "path/filepath" ++ "strings" + + "pccontroller.local/controller/internal/native" + ) +@@ -44,36 +45,69 @@ func GenerateProgrammingEEPROMIntelHex() ([]byte, error) { + return generateEEPROMIntelHex(factory) + } + ++// GenerateMigratedProgrammingEEPROMIntelHex converts a validated pre-flash ++// backup semantically into the compiled production schema. It preserves every ++// unrelated EEPROM byte, drops only schema-2 menu-layout storage, recalculates ++// CRC-8, and arms Silent/Prog before the new firmware is allowed to boot. ++func GenerateMigratedProgrammingEEPROMIntelHex(manifestPath string) ([]byte, OfflineSettingsDecode, error) { ++ _, backup, decoded, err := loadCurrentBackupEEPROM(manifestPath) ++ if err != nil { ++ return nil, OfflineSettingsDecode{}, err ++ } ++ values := decoded.Settings.Values ++ values.Flags |= native.SettingsSilent | native.SettingsProgrammingMode ++ values.IlluminationMode = 0 ++ values.IlluminationOnBrightness = 0 ++ values.IlluminationOffBrightness = 0 ++ values.StatusBrightness = 0 ++ values.OutputPersistence = 0 ++ values.RelayRestoreMask = 0 ++ values.DisplayClosedBrightness = values.DisplayBrightness ++ record, err := encodeCurrentEEPROMSettingsRecord(values) ++ if err != nil { ++ return nil, OfflineSettingsDecode{}, fmt.Errorf("migrate EEPROM settings to schema 1: %w", err) ++ } ++ migrated := &IntelHexImage{data: make(map[uint32]byte, len(backup.Image.data))} ++ for address, value := range backup.Image.data { ++ migrated.data[address] = value ++ } ++ // Clear the superseded schema-2 tail so a future forensic decoder cannot ++ // mistake stale menu-layout/name bytes for a second live record. ++ for offset := uint32(0); offset < EEPROMMenuLayoutRecordBytes; offset++ { ++ migrated.data[EEPROMSettingsAddress+offset] = 0xFF ++ } ++ for offset, value := range record { ++ migrated.data[EEPROMSettingsAddress+uint32(offset)] = value ++ } ++ content, err := migrated.Canonical() ++ if err != nil { ++ return nil, OfflineSettingsDecode{}, fmt.Errorf("encode migrated EEPROM: %w", err) ++ } ++ verification, err := ParseIntelHex(strings.NewReader(string(content))) ++ if err != nil { ++ return nil, OfflineSettingsDecode{}, err ++ } ++ if err := requireFullEEPROMImage(verification); err != nil { ++ return nil, OfflineSettingsDecode{}, err ++ } ++ confirmed := decodeOfflineSettings(verification) ++ wantFlags := native.SettingsSilent | native.SettingsProgrammingMode ++ if !confirmed.Valid || confirmed.Schema != EEPROMSettingsRecordSchema || confirmed.Values.Flags&wantFlags != wantFlags { ++ return nil, OfflineSettingsDecode{}, fmt.Errorf("migrated EEPROM failed schema-1 Silent/Prog verification: %s", confirmed.Issue) ++ } ++ return content, confirmed, nil ++} ++ + func generateEEPROMIntelHex(factory native.Settings) ([]byte, error) { + data := make([]byte, PCControllerEEPROMBytes) + for index := range data { + data[index] = 0xFF + } +- settings := data[EEPROMSettingsAddress : EEPROMSettingsAddress+EEPROMSettingsRecordBytes] +- values := settings[:EEPROMSettingsValueBytes] +- values[0] = factory.Flags +- values[1] = factory.LightMode +- values[2] = factory.OnBrightness +- values[3] = factory.OffBrightness +- values[4] = factory.DisplayBrightness +- values[5] = factory.StatusBrightness +- values[6] = factory.OutputPersistence +- binary.LittleEndian.PutUint16(values[7:9], factory.StreamPeriodMS) +- for index := 9; index < 17; index++ { +- values[index] = 0 // eight safe, off user-PWM defaults +- } +- values[17] = factory.DefaultPage +- values[18] = factory.ExtendedFlags +- binary.LittleEndian.PutUint16(values[19:21], DefaultVisibleMenuMask) +- copy(values[21:28], []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC}) +- values[28] = factory.DisplayClosedBrightness | +- (factory.MotionExitHoldSeconds << 3) +- values[29] = factory.RelayRestoreMask +- values[30] = factory.MotionBreakMSValue +- for index := 31; index < len(values); index++ { +- values[index] = 0 // valid empty board name plus deterministic padding +- } +- settings[EEPROMSettingsValueBytes] = avrCRC8(values) ++ settings, err := encodeCurrentEEPROMSettingsRecord(controllerSettingsFromNative(factory)) ++ if err != nil { ++ return nil, fmt.Errorf("encode safe settings EEPROM: %w", err) ++ } ++ copy(data[EEPROMSettingsAddress:EEPROMSettingsAddress+EEPROMSettingsRecordBytes], settings) + + // A new board starts with a valid empty 20-record learned-RF store. + header := data[EEPROMRemoteHeaderAddress : EEPROMRemoteHeaderAddress+4] +@@ -196,6 +230,51 @@ func ProgramLatchedFactoryEEPROM( + return nil + } + ++// ProgramMigratedProgrammingEEPROM writes a semantically migrated, complete ++// EEPROM image before the application flash. The programmer executor performs ++// its ordinary independent read-back verification. ++func ProgramMigratedProgrammingEEPROM( ++ ctx context.Context, ++ manifestPath string, ++ paths HostDataPaths, ++ base Options, ++ execute EEPROMProgramOperation, ++ output io.Writer, ++) error { ++ if execute == nil { ++ return fmt.Errorf("migrated programming EEPROM requires an executor") ++ } ++ content, decoded, err := GenerateMigratedProgrammingEEPROMIntelHex(manifestPath) ++ if err != nil { ++ return err ++ } ++ temporary, err := os.CreateTemp(paths.StateDir, "migrated-programming-eeprom-*.hex") ++ if err != nil { ++ return fmt.Errorf("reserve migrated programming EEPROM image: %w", err) ++ } ++ path := temporary.Name() ++ if err := temporary.Close(); err != nil { ++ _ = os.Remove(path) ++ return err ++ } ++ if err := os.Remove(path); err != nil { ++ return fmt.Errorf("prepare migrated programming EEPROM image: %w", err) ++ } ++ defer os.Remove(path) ++ if err := writeEEPROMIntelHexExclusive(path, content); err != nil { ++ return err ++ } ++ write := base ++ write.Operation = OperationWriteEEPROM ++ write.HexPath = path ++ write.OutputPath = "" ++ write.ConfirmEEPROMWrite = true ++ if err := execute(ctx, write, output); err != nil { ++ return fmt.Errorf("program migrated schema-%d EEPROM: %w", decoded.Schema, err) ++ } ++ return nil ++} ++ + // stageDefaultEEPROMCompileArtifact publishes the canonical deployment EEPROM + // image beside the application and bootloader outputs. + func stageDefaultEEPROMCompileArtifact(outputDir string) (string, error) { +diff --git a/Tools/Controller/internal/programmer/default_eeprom_test.go b/Tools/Controller/internal/programmer/default_eeprom_test.go +index 744d4dbe..0b3fee04 100644 +--- a/Tools/Controller/internal/programmer/default_eeprom_test.go ++++ b/Tools/Controller/internal/programmer/default_eeprom_test.go +@@ -35,18 +35,17 @@ func TestGenerateDefaultEEPROMIntelHexCreatesSafeCurrentSettings(t *testing.T) { + if record[EEPROMSettingsValueBytes] != avrCRC8(record[:EEPROMSettingsValueBytes]) { + t.Fatal("default settings CRC does not match") + } +- wantOrder := []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC} +- if !bytes.Equal(record[21:28], wantOrder) || record[28]&0x07 != 0 { +- t.Fatalf("dense menu order / closed brightness = % X / %d", record[21:28], record[28]) ++ if record[19]&0x07 != 0 { ++ t.Fatalf("closed brightness = %d", record[19]&0x07) + } + if record[1] != 0 || record[6] != 0 { + t.Fatalf("deployment defaults must keep illumination/PWM off: mode=%d pwm=%d", record[1], record[6]) + } +- if record[30] != 1 { +- t.Fatalf("default motion break=%d ms, want 1", record[30]) ++ if record[21] != 1 { ++ t.Fatalf("default motion break=%d ms, want 1", record[21]) + } + decoded := decodeOfflineSettings(image) +- if !decoded.Valid || decoded.Values.DefaultMenuPage != 0 || decoded.Values.VisibleMenuMask != DefaultVisibleMenuMask { ++ if !decoded.Valid || decoded.Schema != EEPROMSettingsRecordSchema || decoded.Values.DefaultMenuPage != 0 { + t.Fatalf("generated default settings = %#v", decoded) + } + remotes := decodeOfflineRemotes(image) +@@ -90,11 +89,11 @@ func TestGenerateProgrammingEEPROMIntelHexArmsQuietVisibleLatch(t *testing.T) { + } + wantFlags := native.SettingsSilent | native.SettingsProgrammingMode + if record[0]&wantFlags != wantFlags || record[1] != 0 || record[2] != 0 || +- record[3] != 0 || record[5] != 0 || record[6] != 0 || record[29] != 0 { ++ record[3] != 0 || record[5] != 0 || record[6] != 0 || record[20] != 0 { + t.Fatalf("programming image is not latched/off: % X", record) + } +- if record[4] == 0 || record[28]&0x07 != record[4] { +- t.Fatalf("Prog must remain visible with door closed: open=%d closed=%d", record[4], record[28]&0x07) ++ if record[4] == 0 || record[19]&0x07 != record[4] { ++ t.Fatalf("Prog must remain visible with door closed: open=%d closed=%d", record[4], record[19]&0x07) + } + decoded := decodeOfflineSettings(image) + if !decoded.Valid || decoded.Values.Flags&wantFlags != wantFlags { +diff --git a/Tools/Controller/internal/programmer/eeprom.go b/Tools/Controller/internal/programmer/eeprom.go +index 5dca5819..1f58432b 100644 +--- a/Tools/Controller/internal/programmer/eeprom.go ++++ b/Tools/Controller/internal/programmer/eeprom.go +@@ -2,6 +2,7 @@ package programmer + + import ( + "encoding/binary" ++ "errors" + "fmt" + "strings" + +@@ -9,22 +10,28 @@ import ( + ) + + const ( +- PCControllerEEPROMBytes uint32 = generatedBoardEEPROMBytes +- EEPROMSettingsAddress uint32 = 32 +- EEPROMSettingsValueBytes uint32 = 40 +- EEPROMSettingsRecordBytes uint32 = EEPROMSettingsValueBytes + 1 +- EEPROMRemoteHeaderAddress uint32 = 80 +- EEPROMRemoteEntriesAddress uint32 = 84 +- EEPROMRemoteRecordSize byte = 12 +- EEPROMRemoteCapacity byte = 20 +- EEPROMRemoteRecordBytes uint32 = 12 +- EEPROMResetJournalAddress uint32 = 336 +- EEPROMResetJournalSlots byte = 64 +- EEPROMResetJournalRecordSize uint32 = 6 +- EEPROMStatusProfileAddress uint32 = EEPROMResetJournalAddress + uint32(EEPROMResetJournalSlots)*EEPROMResetJournalRecordSize +- EEPROMStatusProfileCount byte = 19 +- EEPROMStatusProfileBytes uint32 = 12 +- EEPROMStatusProfileRecordBytes uint32 = EEPROMStatusProfileBytes + 1 ++ PCControllerEEPROMBytes uint32 = generatedBoardEEPROMBytes ++ EEPROMSettingsAddress uint32 = 32 ++ EEPROMSettingsRecordSchema byte = 1 ++ EEPROMSettingsControllerBytes uint32 = 22 ++ EEPROMSettingsValueBytes uint32 = EEPROMSettingsControllerBytes + 1 + native.MaximumBoardNameLength ++ EEPROMSettingsRecordBytes uint32 = EEPROMSettingsValueBytes + 1 ++ EEPROMMenuLayoutRecordSchema byte = 2 ++ EEPROMMenuLayoutControllerBytes uint32 = 31 ++ EEPROMMenuLayoutValueBytes uint32 = EEPROMMenuLayoutControllerBytes + 1 + native.MaximumBoardNameLength ++ EEPROMMenuLayoutRecordBytes uint32 = EEPROMMenuLayoutValueBytes + 1 ++ EEPROMRemoteHeaderAddress uint32 = 80 ++ EEPROMRemoteEntriesAddress uint32 = 84 ++ EEPROMRemoteRecordSize byte = 12 ++ EEPROMRemoteCapacity byte = 20 ++ EEPROMRemoteRecordBytes uint32 = 12 ++ EEPROMResetJournalAddress uint32 = 336 ++ EEPROMResetJournalSlots byte = 64 ++ EEPROMResetJournalRecordSize uint32 = 6 ++ EEPROMStatusProfileAddress uint32 = EEPROMResetJournalAddress + uint32(EEPROMResetJournalSlots)*EEPROMResetJournalRecordSize ++ EEPROMStatusProfileCount byte = 19 ++ EEPROMStatusProfileBytes uint32 = 12 ++ EEPROMStatusProfileRecordBytes uint32 = EEPROMStatusProfileBytes + 1 + ) + + type OfflineEEPROMDecode struct { +@@ -75,6 +82,7 @@ type OfflineSettingsDecode struct { + Supported bool `json:"supported"` + Valid bool `json:"valid"` + Format string `json:"format"` ++ Schema byte `json:"schema"` + ValueBytes uint32 `json:"value_bytes"` + Issue string `json:"issue,omitempty"` + StoredChecksum byte `json:"stored_checksum"` +@@ -151,7 +159,7 @@ func DecodeOfflineEEPROMHex(path string) (OfflineEEPROMDecode, error) { + decoded := OfflineEEPROMDecode{ + SourceKind: "offline-eeprom-hex", + SourcePath: path, SourceSHA256: document.SourceSHA256, +- Layout: "settings-name-unversioned-40/rf-record12-cap20/reset-journal-336", ++ Layout: "settings-schema1-core22-record32/rf-record12-cap20/reset-journal-336", + } + decoded.Settings = decodeOfflineSettings(document.Image) + decoded.Remotes = decodeOfflineRemotes(document.Image) +@@ -160,32 +168,58 @@ func DecodeOfflineEEPROMHex(path string) (OfflineEEPROMDecode, error) { + } + + func decodeOfflineSettings(image *IntelHexImage) OfflineSettingsDecode { +- record, err := image.BytesAt( +- EEPROMSettingsAddress, +- EEPROMSettingsRecordBytes, +- ) +- if err != nil { ++ currentRecord, currentErr := image.BytesAt(EEPROMSettingsAddress, EEPROMSettingsRecordBytes) ++ legacyRecord, legacyErr := image.BytesAt(EEPROMSettingsAddress, EEPROMMenuLayoutRecordBytes) ++ var current, legacy OfflineSettingsDecode ++ if currentErr == nil { ++ current = decodeOfflineSettingsRecord(currentRecord, false) ++ } ++ if legacyErr == nil { ++ legacy = decodeOfflineSettingsRecord(legacyRecord, true) ++ } ++ switch { ++ case current.Valid && !legacy.Valid: ++ return current ++ case legacy.Valid && !current.Valid: ++ return legacy ++ case current.Valid && legacy.Valid: ++ current.Valid = false ++ current.Issue = "ambiguous settings record validates as both schema 1 and schema 2" ++ return current ++ case currentErr == nil: ++ if legacyErr == nil && legacy.Issue != "" { ++ current.Issue += "; schema 2: " + legacy.Issue ++ } ++ return current ++ default: + _, present := image.data[EEPROMSettingsAddress] + return OfflineSettingsDecode{ + Present: present, +- Format: "current/unversioned-40+crc8", ++ Format: "unsupported", + Issue: fmt.Sprintf( +- "unsupported settings layout: require 40 value bytes plus CRC-8 at EEPROM 0x%04X..0x%04X: %v", +- EEPROMSettingsAddress, +- EEPROMSettingsAddress+EEPROMSettingsRecordBytes-1, +- err, ++ "unsupported settings layout: require schema 1 record (%d bytes) at EEPROM 0x%04X..0x%04X: %v", ++ EEPROMSettingsRecordBytes, EEPROMSettingsAddress, ++ EEPROMSettingsAddress+EEPROMSettingsRecordBytes-1, currentErr, + ), + } + } +- return decodeOfflineSettingsRecord(record) + } + +-func decodeOfflineSettingsRecord(record []byte) OfflineSettingsDecode { ++func decodeOfflineSettingsRecord(record []byte, menuLayout bool) OfflineSettingsDecode { ++ valueBytes := EEPROMSettingsValueBytes ++ format := "schema1/core22+name9+crc8" ++ schema := EEPROMSettingsRecordSchema ++ if menuLayout { ++ valueBytes = EEPROMMenuLayoutValueBytes ++ format = "schema2/menu-layout31+name9+crc8" ++ schema = EEPROMMenuLayoutRecordSchema ++ } + result := OfflineSettingsDecode{ + Present: true, + Supported: true, +- Format: "current/unversioned-40+crc8", +- ValueBytes: EEPROMSettingsValueBytes, ++ Format: format, ++ Schema: schema, ++ ValueBytes: valueBytes, + StoredChecksum: record[len(record)-1], + ComputedChecksum: avrCRC8(record[:len(record)-1]), + } +@@ -207,23 +241,35 @@ func decodeOfflineSettingsRecord(record []byte) OfflineSettingsDecode { + values.MotionDoorPolicy = (values.Flags >> 3) & 0x03 + values.DoorAudioEnabled = values.Flags&0x20 == 0 + values.RelayAudioEnabled = values.Flags&0x40 == 0 +- values.MotionBreakMS = uint16(settings[30]) + values.SaveLastMenuPage = values.MenuFlags&0x01 != 0 + values.StatusColor = (values.MenuFlags >> 1) & 0x07 + values.VoltageDecimals = decodeDecimalBits((values.MenuFlags >> 4) & 0x03) + values.CurrentDecimals = decodeDecimalBits((values.MenuFlags >> 6) & 0x03) +- values.VisibleMenuMask = binary.LittleEndian.Uint16(settings[19:21]) +- copy(values.MenuOrder[:], settings[21:28]) +- displayOptions := settings[28] ++ displayOptionsOffset := 19 ++ relayRestoreOffset := 20 ++ motionBreakOffset := 21 ++ nameLengthOffset := 22 ++ nameOffset := 23 ++ if menuLayout { ++ values.VisibleMenuMask = binary.LittleEndian.Uint16(settings[19:21]) ++ copy(values.MenuOrder[:], settings[21:28]) ++ displayOptionsOffset = 28 ++ relayRestoreOffset = 29 ++ motionBreakOffset = 30 ++ nameLengthOffset = 31 ++ nameOffset = 32 ++ } ++ displayOptions := settings[displayOptionsOffset] + values.DisplayClosedBrightness = displayOptions & 0x07 + values.MotionExitHoldSeconds = displayOptions >> 3 + if values.MotionExitHoldSeconds == 0 { + values.MotionExitHoldSeconds = 2 + } +- values.RelayRestoreMask = settings[29] +- nameLength := settings[31] ++ values.RelayRestoreMask = settings[relayRestoreOffset] ++ values.MotionBreakMS = uint16(settings[motionBreakOffset]) ++ nameLength := settings[nameLengthOffset] + if nameLength <= native.MaximumBoardNameLength { +- values.BoardName = string(settings[32 : 32+nameLength]) ++ values.BoardName = string(settings[nameOffset : nameOffset+int(nameLength)]) + } + result.Values = values + +@@ -254,12 +300,75 @@ func decodeOfflineSettingsRecord(record []byte) OfflineSettingsDecode { + } else if err := native.ValidateBoardName(values.BoardName); err != nil { + issues = append(issues, err.Error()) + } +- issues = append(issues, validateOfflineMenuLayout(values)...) ++ if menuLayout { ++ issues = append(issues, validateOfflineMenuLayout(values)...) ++ } + result.Valid = len(issues) == 0 + result.Issue = strings.Join(issues, "; ") + return result + } + ++func controllerSettingsFromNative(settings native.Settings) ControllerSettings { ++ return ControllerSettings{ ++ Flags: settings.Flags, ++ IlluminationMode: settings.LightMode, ++ IlluminationOnBrightness: settings.OnBrightness, ++ IlluminationOffBrightness: settings.OffBrightness, ++ DisplayBrightness: settings.DisplayBrightness, ++ StatusBrightness: settings.StatusBrightness, ++ OutputPersistence: settings.OutputPersistence, ++ StreamPeriodMS: settings.StreamPeriodMS, ++ DefaultMenuPage: settings.DefaultPage, ++ MenuFlags: settings.ExtendedFlags, ++ DisplayClosedBrightness: settings.DisplayClosedBrightness, ++ MotionExitHoldSeconds: settings.MotionExitHoldSeconds, ++ RelayRestoreMask: settings.RelayRestoreMask, ++ MotionBreakMS: uint16(settings.MotionBreakMSValue), ++ } ++} ++ ++// encodeCurrentEEPROMSettingsRecord is the only host encoder for the ++// production schema-1 record. Menu-layout fields intentionally do not enter ++// this alpha layout; they are independent protocol data when compiled in. ++func encodeCurrentEEPROMSettingsRecord(values ControllerSettings) ([]byte, error) { ++ if values.MotionBreakMS == 0 || values.MotionBreakMS > 255 { ++ return nil, errors.New("motion break is outside 1..255 ms") ++ } ++ if err := native.ValidateBoardName(values.BoardName); err != nil { ++ return nil, err ++ } ++ if values.MotionExitHoldSeconds > native.SettingsMaximumMotionExitHoldSeconds { ++ return nil, fmt.Errorf("motion exit hold %d seconds exceeds %d", values.MotionExitHoldSeconds, native.SettingsMaximumMotionExitHoldSeconds) ++ } ++ record := make([]byte, EEPROMSettingsRecordBytes) ++ record[0] = values.Flags ++ record[1] = values.IlluminationMode ++ record[2] = values.IlluminationOnBrightness ++ record[3] = values.IlluminationOffBrightness ++ record[4] = values.DisplayBrightness ++ record[5] = values.StatusBrightness ++ record[6] = values.OutputPersistence ++ binary.LittleEndian.PutUint16(record[7:9], values.StreamPeriodMS) ++ copy(record[9:17], values.UserPWM[:]) ++ record[17] = values.DefaultMenuPage ++ record[18] = values.MenuFlags ++ hold := values.MotionExitHoldSeconds ++ if hold == native.SettingsDefaultMotionExitHoldSeconds { ++ hold = 0 ++ } ++ record[19] = values.DisplayClosedBrightness | hold<<3 ++ record[20] = values.RelayRestoreMask ++ record[21] = byte(values.MotionBreakMS) ++ record[22] = byte(len(values.BoardName)) ++ copy(record[23:31], []byte(values.BoardName)) ++ record[EEPROMSettingsValueBytes] = avrCRC8(record[:EEPROMSettingsValueBytes]) ++ decoded := decodeOfflineSettingsRecord(record, false) ++ if !decoded.Valid { ++ return nil, fmt.Errorf("encode schema-1 EEPROM settings: %s", decoded.Issue) ++ } ++ return record, nil ++} ++ + func validateOfflineMenuLayout(values ControllerSettings) []string { + const allPages uint16 = 0x3FFF + var issues []string +diff --git a/Tools/Controller/internal/programmer/eeprom_test.go b/Tools/Controller/internal/programmer/eeprom_test.go +index 77877b4b..170172ac 100644 +--- a/Tools/Controller/internal/programmer/eeprom_test.go ++++ b/Tools/Controller/internal/programmer/eeprom_test.go +@@ -23,13 +23,11 @@ func TestDecodeOfflineEEPROMCurrentSemanticLayout(t *testing.T) { + copy(values[9:17], []byte{1, 2, 3, 4, 5, 6, 7, 8}) + values[17] = 13 + values[18] = 0x01 | 0x06 | 0x10 | 0xC0 +- binary.LittleEndian.PutUint16(values[19:21], 0x3FFF) +- copy(values[21:28], []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC}) +- values[28] = (9 << 3) | 2 +- values[29] = 0xF0 +- values[30] = 100 +- values[31] = 7 +- copy(values[32:40], []byte("EDGE-01")) ++ values[19] = (9 << 3) | 2 ++ values[20] = 0xF0 ++ values[21] = 100 ++ values[22] = 7 ++ copy(values[23:31], []byte("EDGE-01")) + settings[EEPROMSettingsValueBytes] = avrCRC8(values) + + header := data[EEPROMRemoteHeaderAddress : EEPROMRemoteHeaderAddress+4] +@@ -61,10 +59,10 @@ func TestDecodeOfflineEEPROMCurrentSemanticLayout(t *testing.T) { + t.Fatal(err) + } + if decoded.SourceKind != "offline-eeprom-hex" || +- decoded.Layout != "settings-name-unversioned-40/rf-record12-cap20/reset-journal-336" || ++ decoded.Layout != "settings-schema1-core22-record32/rf-record12-cap20/reset-journal-336" || + !decoded.Settings.Supported || !decoded.Settings.Valid || +- decoded.Settings.Format != "current/unversioned-40+crc8" || +- decoded.Settings.ValueBytes != 40 { ++ decoded.Settings.Format != "schema1/core22+name9+crc8" || ++ decoded.Settings.Schema != EEPROMSettingsRecordSchema || decoded.Settings.ValueBytes != 31 { + t.Fatalf("settings decode invalid: %#v", decoded.Settings) + } + settings := decoded.Settings.Values +@@ -73,7 +71,6 @@ func TestDecodeOfflineEEPROMCurrentSemanticLayout(t *testing.T) { + settings.UserPWM[7] != 8 || settings.DefaultMenuPage != 13 || + settings.VoltageDecimals != 0 || settings.CurrentDecimals != 2 || + settings.StatusColor != 3 || !settings.SaveLastMenuPage || +- settings.VisibleMenuMask != 0x3FFF || settings.MenuOrder[6] != 0xDC || + settings.DisplayClosedBrightness != 2 || settings.MotionExitHoldSeconds != 9 || + settings.OutputPersistence != 0x06 || settings.RelayRestoreMask != 0xF0 || + settings.BoardName != "EDGE-01" { +@@ -105,10 +102,8 @@ func TestDecodeOfflineEEPROMReportsCRCAndHeaderDamage(t *testing.T) { + values[1] = 1 + values[4] = 5 + binary.LittleEndian.PutUint16(values[7:9], 500) +- binary.LittleEndian.PutUint16(values[19:21], 0x3FFF) +- copy(values[21:28], []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC}) +- values[28] = 0 +- values[30] = 1 ++ values[19] = 0 ++ values[21] = 1 + settings[EEPROMSettingsValueBytes] = 0x99 + header := data[EEPROMRemoteHeaderAddress : EEPROMRemoteHeaderAddress+4] + binary.LittleEndian.PutUint16(header[0:2], 0x4C52) +@@ -199,8 +194,8 @@ func TestDecodeOfflineEEPROMRejectsInvalidCurrentMenuLayout(t *testing.T) { + } { + t.Run(test.name, func(t *testing.T) { + path := writeEEPROMFixture(t, func(data []byte) { +- record := data[EEPROMSettingsAddress : EEPROMSettingsAddress+EEPROMSettingsRecordBytes] +- values := record[:EEPROMSettingsValueBytes] ++ record := data[EEPROMSettingsAddress : EEPROMSettingsAddress+EEPROMMenuLayoutRecordBytes] ++ values := record[:EEPROMMenuLayoutValueBytes] + values[1] = 1 + values[4] = 5 + values[5] = 128 +@@ -213,7 +208,7 @@ func TestDecodeOfflineEEPROMRejectsInvalidCurrentMenuLayout(t *testing.T) { + for index := 31; index < int(EEPROMSettingsValueBytes); index++ { + values[index] = 0 + } +- record[EEPROMSettingsValueBytes] = avrCRC8(values) ++ record[EEPROMMenuLayoutValueBytes] = avrCRC8(values) + }) + decoded, err := DecodeOfflineEEPROMHex(path) + if err != nil { +diff --git a/Tools/Controller/internal/programmer/eeprom_transfer.go b/Tools/Controller/internal/programmer/eeprom_transfer.go +index 64f1ad34..1c0a858a 100644 +--- a/Tools/Controller/internal/programmer/eeprom_transfer.go ++++ b/Tools/Controller/internal/programmer/eeprom_transfer.go +@@ -34,7 +34,8 @@ func ExportCurrentEEPROMSettings( + if err != nil { + return EEPROMTransferResult{}, err + } +- record, err := document.Image.BytesAt(EEPROMSettingsAddress, EEPROMSettingsRecordBytes) ++ _ = document ++ record, err := encodeCurrentEEPROMSettingsRecord(decoded.Settings.Values) + if err != nil { + return EEPROMTransferResult{}, err + } +@@ -51,7 +52,7 @@ func ExportCurrentEEPROMSettings( + return EEPROMTransferResult{}, err + } + return newEEPROMTransferResult( +- "export", backup, decoded.Settings.Format, "", "", output, content, ++ "export", backup, "schema1/core22+name9+crc8", "", "", output, content, + ), nil + } + +@@ -79,6 +80,9 @@ func ImportCurrentEEPROMSettings( + for address, value := range base.Image.data { + merged.data[address] = value + } ++ for offset := uint32(0); offset < EEPROMMenuLayoutRecordBytes; offset++ { ++ merged.data[EEPROMSettingsAddress+offset] = 0xFF ++ } + for offset, value := range record { + merged.data[EEPROMSettingsAddress+uint32(offset)] = value + } +@@ -121,7 +125,21 @@ func PrepareCurrentEEPROMRestore( + if err != nil { + return EEPROMTransferResult{}, err + } +- content, err := document.Image.Canonical() ++ migrated := &IntelHexImage{data: make(map[uint32]byte, len(document.Image.data))} ++ for address, value := range document.Image.data { ++ migrated.data[address] = value ++ } ++ for offset := uint32(0); offset < EEPROMMenuLayoutRecordBytes; offset++ { ++ migrated.data[EEPROMSettingsAddress+offset] = 0xFF ++ } ++ record, err := encodeCurrentEEPROMSettingsRecord(decoded.Settings.Values) ++ if err != nil { ++ return EEPROMTransferResult{}, fmt.Errorf("semantically migrate backup settings: %w", err) ++ } ++ for offset, value := range record { ++ migrated.data[EEPROMSettingsAddress+uint32(offset)] = value ++ } ++ content, err := migrated.Canonical() + if err != nil { + return EEPROMTransferResult{}, fmt.Errorf("encode restore EEPROM image: %w", err) + } +@@ -130,7 +148,7 @@ func PrepareCurrentEEPROMRestore( + return EEPROMTransferResult{}, err + } + return newEEPROMTransferResult( +- "restore", backup, decoded.Settings.Format, "", "", output, content, ++ "restore", backup, "schema1/core22+name9+crc8", "", "", output, content, + ), nil + } + +diff --git a/Tools/Controller/internal/programmer/eeprom_transfer_test.go b/Tools/Controller/internal/programmer/eeprom_transfer_test.go +index fb830609..1ca6f357 100644 +--- a/Tools/Controller/internal/programmer/eeprom_transfer_test.go ++++ b/Tools/Controller/internal/programmer/eeprom_transfer_test.go +@@ -18,7 +18,7 @@ func TestCurrentEEPROMExportImportAndRestoreRequireValidatedBackup(t *testing.T) + if err != nil { + t.Fatal(err) + } +- if exportResult.Action != "export" || exportResult.SettingsFormat != "current/unversioned-40+crc8" { ++ if exportResult.Action != "export" || exportResult.SettingsFormat != "schema1/core22+name9+crc8" { + t.Fatalf("unexpected export result: %+v", exportResult) + } + exportDocument, exportDecoded, err := loadCurrentSettingsArtifact(exported) +@@ -87,6 +87,38 @@ func TestCurrentEEPROMExportImportAndRestoreRequireValidatedBackup(t *testing.T) + } + } + ++func TestLegacyBackupMigratesSemanticallyAndArmsProgBeforeFlash(t *testing.T) { ++ manifest := currentEEPROMBackupManifest(t) // fixture is schema-2 alpha layout ++ content, decoded, err := GenerateMigratedProgrammingEEPROMIntelHex(manifest) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if decoded.Schema != EEPROMSettingsRecordSchema || !decoded.Valid || ++ decoded.Values.Flags&(0x01|0x02) != 0x03 || decoded.Values.StreamPeriodMS != 500 { ++ t.Fatalf("migration did not preserve semantics behind Silent/Prog: %#v", decoded) ++ } ++ document, err := ParseIntelHex(strings.NewReader(string(content))) ++ if err != nil { ++ t.Fatal(err) ++ } ++ sentinel, err := document.BytesAt(500, 1) ++ if err != nil || sentinel[0] != 0xA5 { ++ t.Fatalf("migration changed unrelated EEPROM: % X err=%v", sentinel, err) ++ } ++ tail, err := document.BytesAt( ++ EEPROMSettingsAddress+EEPROMSettingsRecordBytes, ++ EEPROMMenuLayoutRecordBytes-EEPROMSettingsRecordBytes, ++ ) ++ if err != nil { ++ t.Fatal(err) ++ } ++ for offset, value := range tail { ++ if value != 0xFF { ++ t.Fatalf("legacy raw tail byte %d was replayed: 0x%02X", offset, value) ++ } ++ } ++} ++ + func TestCurrentEEPROMTransferRejectsIncompleteBackupImage(t *testing.T) { + root := t.TempDir() + directory, err := BackupWithRunner( +@@ -109,7 +141,7 @@ func currentEEPROMBackupManifest(t *testing.T) string { + for index := range data { + data[index] = 0xFF + } +- values := data[EEPROMSettingsAddress : EEPROMSettingsAddress+EEPROMSettingsValueBytes] ++ values := data[EEPROMSettingsAddress : EEPROMSettingsAddress+EEPROMMenuLayoutValueBytes] + values[0] = 0 + values[1] = 1 + values[2] = 180 +@@ -122,10 +154,11 @@ func currentEEPROMBackupManifest(t *testing.T) string { + binary.LittleEndian.PutUint16(values[19:21], 0x3FFF) + copy(values[21:28], []byte{0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC}) + values[28] = 0 +- for index := 31; index < int(EEPROMSettingsValueBytes); index++ { ++ values[30] = 1 ++ for index := 31; index < int(EEPROMMenuLayoutValueBytes); index++ { + values[index] = 0 + } +- data[EEPROMSettingsAddress+EEPROMSettingsValueBytes] = avrCRC8(values) ++ data[EEPROMSettingsAddress+EEPROMMenuLayoutValueBytes] = avrCRC8(values) + data[500] = 0xA5 + image := &IntelHexImage{data: make(map[uint32]byte, len(data))} + for address, value := range data { +diff --git a/Tools/Controller/internal/programmer/programmer.go b/Tools/Controller/internal/programmer/programmer.go +index 7d0371b5..ddab8b40 100644 +--- a/Tools/Controller/internal/programmer/programmer.go ++++ b/Tools/Controller/internal/programmer/programmer.go +@@ -963,7 +963,7 @@ func ValidateBackup(options Options) error { + } + if options.ApplicationPackedTimestamp != 0 { + if !currentIdentitySchema(options.ApplicationIdentitySchema) { +- return errors.New("packed firmware timestamp requires compact identity schema 3") ++ return errors.New("packed firmware timestamp requires compact identity schema 4") + } + if _, err := DecodeFirmwareTimestamp(options.ApplicationPackedTimestamp); err != nil { + return err +@@ -972,7 +972,7 @@ func ValidateBackup(options Options) error { + return nil + } + +-func currentIdentitySchema(schema byte) bool { return schema == 3 } ++func currentIdentitySchema(schema byte) bool { return schema == 4 } + + func createBackupDirectory(root string, timestamp time.Time) (string, error) { + if err := os.MkdirAll(root, 0o755); err != nil { +diff --git a/Tools/Controller/internal/programmer/restore.go b/Tools/Controller/internal/programmer/restore.go +index 9ef93d80..3c0d110d 100644 +--- a/Tools/Controller/internal/programmer/restore.go ++++ b/Tools/Controller/internal/programmer/restore.go +@@ -63,7 +63,7 @@ func ValidateBackupManifest(manifestPath string) (ValidatedBackup, error) { + } + if manifest.ApplicationPackedTimestamp != "" { + if !currentIdentitySchema(manifest.ApplicationIdentitySchema) { +- return ValidatedBackup{}, errors.New("packed firmware timestamp requires compact identity schema 3") ++ return ValidatedBackup{}, errors.New("packed firmware timestamp requires compact identity schema 4") + } + packed, parseErr := strconv.ParseUint(manifest.ApplicationPackedTimestamp, 16, 32) + if parseErr != nil || len(manifest.ApplicationPackedTimestamp) != 8 { +diff --git a/Tools/Controller/internal/programmer/restore_preflash_test.go b/Tools/Controller/internal/programmer/restore_preflash_test.go +index fff48f20..280ae4b3 100644 +--- a/Tools/Controller/internal/programmer/restore_preflash_test.go ++++ b/Tools/Controller/internal/programmer/restore_preflash_test.go +@@ -71,7 +71,7 @@ func TestBackupManifestContentAddressingValidationAndRestorePlan(t *testing.T) { + date := uint32((2026-2000)<<9 | 8<<5 | 1) + clock := uint32(19<<11 | 42<<5 | 58>>1) + options := fakeBackupOptions(root) +- options.ApplicationIdentitySchema = 3 ++ options.ApplicationIdentitySchema = 4 + options.ApplicationPackedTimestamp = date<<16 | clock + directory, err := BackupWithRunner(context.Background(), options, io.Discard, runner) + if err != nil { +@@ -119,12 +119,12 @@ func TestValidateBackupAcceptsCurrentPackedIdentitySchema(t *testing.T) { + date := uint32((2026-2000)<<9 | 8<<5 | 1) + clock := uint32(19<<11 | 42<<5 | 58>>1) + options := fakeBackupOptions(t.TempDir()) +- options.ApplicationIdentitySchema = 3 ++ options.ApplicationIdentitySchema = 4 + options.ApplicationPackedTimestamp = date<<16 | clock + if err := ValidateBackup(options); err != nil { +- t.Fatalf("schema 3: %v", err) ++ t.Fatalf("schema 4: %v", err) + } +- for _, schema := range []byte{1, 2} { ++ for _, schema := range []byte{1, 2, 3} { + options := fakeBackupOptions(t.TempDir()) + options.ApplicationIdentitySchema = schema + options.ApplicationPackedTimestamp = date<<16 | clock +diff --git a/Tools/Controller/internal/programmer/stack_budget.go b/Tools/Controller/internal/programmer/stack_budget.go +index 74714b26..ac031fdb 100644 +--- a/Tools/Controller/internal/programmer/stack_budget.go ++++ b/Tools/Controller/internal/programmer/stack_budget.go +@@ -287,7 +287,20 @@ func buildSerialStackPath(listing *avrListing) ([]compileManifestStackStage, str + if err != nil { + return nil, "", 0, err + } +- writeCobsStage, err := functionOrInlineStage(listing, sendStage.Function, listingFunctionSpec{ ++ timestampedStage, err := functionOrInlineStage(listing, sendStage.Function, listingFunctionSpec{ ++ Name: "timestamped UART response", Match: "ControllerProtocol::UartProtocol::sendTimestamped(", InlineLabel: "sendTimestamped", ++ }) ++ if err != nil { ++ return nil, "", 0, err ++ } ++ responseWriter := sendStage.Function ++ if timestampedStage.Function != nil { ++ if !functionCalls(sendStage.Function, timestampedStage.Function) { ++ return nil, "", 0, errors.New("final listing has no UART send -> timestamped response CALL edge") ++ } ++ responseWriter = timestampedStage.Function ++ } ++ writeCobsStage, err := functionOrInlineStage(listing, responseWriter, listingFunctionSpec{ + Name: "COBS response writer", Match: "ControllerProtocol::UartProtocol::writeCobs(", InlineLabel: "writeCobs", + }) + if err != nil { +@@ -301,15 +314,15 @@ func buildSerialStackPath(listing *avrListing) ([]compileManifestStackStage, str + if err != nil { + return nil, "", 0, err + } +- if !functionHasMnemonic(sendStage.Function, "icall") { +- return nil, "", 0, errors.New("UART send no longer has the expected virtual Print write edge") ++ if !functionHasMnemonic(responseWriter, "icall") { ++ return nil, "", 0, errors.New("timestamped UART response no longer has the expected virtual Print write edge") + } + if !functionHasMnemonic(printStage.Function, "icall") { + return nil, "", 0, errors.New("Print buffer writer no longer has the expected virtual byte-write edge") + } + + prefix := []listingStage{mainStage, loopStage, serviceStage, decodeStage, handlerStage} +- common := []listingStage{sendStage, writeCobsStage, printStage, hardwareStage} ++ common := []listingStage{sendStage, timestampedStage, writeCobsStage, printStage, hardwareStage} + if drain, ok, drainErr := optionalCalledListingStage(listing, hardwareStage.Function, listingFunctionSpec{ + Name: "HardwareSerial TX drain", Match: "HardwareSerial::_tx_udr_empty_irq(", + }); drainErr != nil { +diff --git a/Tools/Controller/internal/programmer/stack_budget_test.go b/Tools/Controller/internal/programmer/stack_budget_test.go +index d71c007e..74297b9f 100644 +--- a/Tools/Controller/internal/programmer/stack_budget_test.go ++++ b/Tools/Controller/internal/programmer/stack_budget_test.go +@@ -94,7 +94,8 @@ func TestFinalListingFrameParserAndBudget(t *testing.T) { + "main": 8, + "handleProtocolFrame(Frame)": 24, + "sendTelemetry(unsigned char)": 17, +- "ControllerProtocol::UartProtocol::send(unsigned char)": 2, ++ "ControllerProtocol::UartProtocol::send(unsigned char)": 1, ++ "ControllerProtocol::UartProtocol::sendTimestamped(unsigned char, unsigned long)": 2, + "__vector_1": 3, + "RCSwitch::handleInterrupt()": 14, + "micros": 0, +@@ -115,12 +116,12 @@ func TestFinalListingFrameParserAndBudget(t *testing.T) { + t.Fatal(err) + } + if report.SelectedResponseBranch != "telemetry" || +- report.SerialPathBytes != 70 || report.RFInterruptAllowanceBytes != 23 || +- report.EstimatedPeakSRAMBytes != 1893 || report.EstimatedFreeSRAMBytes != 155 { ++ report.SerialPathBytes != 73 || report.RFInterruptAllowanceBytes != 23 || ++ report.EstimatedPeakSRAMBytes != 1896 || report.EstimatedFreeSRAMBytes != 152 { + t.Fatalf("unexpected budget: %#v", report) + } + serialAllowance := report.SerialPath[len(report.SerialPath)-1] +- if serialAllowance.Bytes != 14 || serialAllowance.Function != "7 active CALL edges" { ++ if serialAllowance.Bytes != 16 || serialAllowance.Function != "8 active CALL edges" { + t.Fatalf("serial return allowance=%#v", serialAllowance) + } + rfAllowance := report.RFInterruptPath[len(report.RFInterruptPath)-1] +@@ -141,19 +142,19 @@ func TestFinalListingAcceptsModularLifecycleInlineMarker(t *testing.T) { + t.Fatal(err) + } + if report.SerialPath[2].Function != "serviceController()" || +- report.SerialPathBytes != 70 { ++ report.SerialPathBytes != 73 { + t.Fatalf("modular lifecycle marker changed stack topology: %#v", report) + } + } + + func TestFinalListingBudgetRejectsLessThanNinetySixBytes(t *testing.T) { + listing := parseListingFixture(t, completeAVRListingFixture()) +- _, err := estimateFirmwareStackBudget(listing, 1860) ++ _, err := estimateFirmwareStackBudget(listing, 1857) + if err == nil || !strings.Contains(err.Error(), "leaving 95 bytes") || + !strings.Contains(err.Error(), "minimum safe margin is 96 bytes") { + t.Fatalf("unexpected guard failure: %v", err) + } +- if report, err := estimateFirmwareStackBudget(listing, 1859); err != nil || ++ if report, err := estimateFirmwareStackBudget(listing, 1856); err != nil || + report.EstimatedFreeSRAMBytes != 96 { + t.Fatalf("exact minimum margin should pass: report=%#v err=%v", report, err) + } +@@ -171,7 +172,7 @@ func TestFinalListingComputesCallEdgesFromSelectedPath(t *testing.T) { + t.Fatal(err) + } + allowance := report.SerialPath[len(report.SerialPath)-1] +- if report.SerialPathBytes != 68 || allowance.Bytes != 12 || allowance.Function != "6 active CALL edges" { ++ if report.SerialPathBytes != 71 || allowance.Bytes != 14 || allowance.Function != "7 active CALL edges" { + t.Fatalf("computed call edges did not follow topology: report=%#v allowance=%#v", report, allowance) + } + } +@@ -226,8 +227,8 @@ func TestPrintFirmwareStackBudgetShowsFinalEvidence(t *testing.T) { + var output bytes.Buffer + printFirmwareStackBudget(&output, report) + for _, expected := range []string{ +- "static 1800 + serial response 70 + RF INT0 23 = 1893/2048 bytes", +- "estimated margin 155 bytes (minimum 96)", ++ "static 1800 + serial response 73 + RF INT0 23 = 1896/2048 bytes", ++ "estimated margin 152 bytes (minimum 96)", + "selected response branch telemetry", + "0 records from 4 .su files", + "opcode handler:", "edge timestamp:", +@@ -238,6 +239,36 @@ func TestPrintFirmwareStackBudgetShowsFinalEvidence(t *testing.T) { + } + } + ++func TestProductionListingTimestampedResponseTopology(t *testing.T) { ++ path := os.Getenv("PCCONTROLLER_FINAL_AVR_LISTING") ++ if path == "" { ++ t.Skip("set PCCONTROLLER_FINAL_AVR_LISTING to validate a linked production listing") ++ } ++ file, err := os.Open(path) ++ if err != nil { ++ t.Fatal(err) ++ } ++ listing, err := parseAVRListing(file) ++ closeErr := file.Close() ++ if err != nil || closeErr != nil { ++ t.Fatalf("parse=%v close=%v", err, closeErr) ++ } ++ report, err := estimateFirmwareStackBudget(listing, 1465) ++ if err != nil { ++ t.Fatal(err) ++ } ++ found := false ++ for _, stage := range report.SerialPath { ++ if stage.Name == "timestamped UART response" { ++ found = true ++ break ++ } ++ } ++ if !found { ++ t.Fatalf("timestamped UART stage missing: %#v", report.SerialPath) ++ } ++} ++ + func parseListingFixture(t *testing.T, value string) *avrListing { + t.Helper() + listing, err := parseAVRListing(strings.NewReader(value)) +@@ -303,9 +334,13 @@ handle(): + + 00001300 : + 1300: 0f 93 push r16 +- 1302: 1f 93 push r17 ++ 1302: 0e 94 c0 09 call 0x1380 ; 0x1380 ++ ++00001380 : ++ 1380: 0f 93 push r16 ++ 1382: 1f 93 push r17 + writeCobs(): +- 1304: 09 95 icall ++ 1384: 09 95 icall + + 00001400 : + 1400: 0f 93 push r16 +diff --git a/Tools/Controller/internal/sessionsnapshot/snapshot_test.go b/Tools/Controller/internal/sessionsnapshot/snapshot_test.go +index 6813b5c3..e5e9fff2 100644 +--- a/Tools/Controller/internal/sessionsnapshot/snapshot_test.go ++++ b/Tools/Controller/internal/sessionsnapshot/snapshot_test.go +@@ -41,7 +41,7 @@ func TestBuildCapturesUsefulStateWithoutSecretsOrReplayableRF(t *testing.T) { + InstanceID: "USB\\VID_1A86&PID_7523\\BOARD-01", + }, + Hello: controller.Hello{ +- BoardKind: 1, Name: "PCController", IdentitySchema: 3, ++ BoardKind: 1, Name: "PCController", IdentitySchema: 4, + Capabilities: 0x12345678, BuildHash: 0xAABBCCDD, + BuildTimestamp: 0x3456789A, BuildStamp: "260802131416", + }, +diff --git a/Tools/Controller/internal/tui/macros.go b/Tools/Controller/internal/tui/macros.go +index 9c0d2c25..0a71452c 100644 +--- a/Tools/Controller/internal/tui/macros.go ++++ b/Tools/Controller/internal/tui/macros.go +@@ -443,7 +443,11 @@ func macroRecordingSummary(state control.MacroRecordingState, now time.Time) str + if elapsed < 0 { + elapsed = 0 + } ++<<<<<<< HEAD + return fmt.Sprintf("%d · %s · %s · %d steps · %s", state.ID, state.Name, state.Category, state.Steps, formatMacroDuration(elapsed)) ++======= ++ return fmt.Sprintf("%d · %s · %s · %d steps (host %d · panel %d · RF %d) · last at %dµs, delta %dµs · %s", state.ID, state.Name, state.Category, state.Steps, state.HostSteps, state.PanelSteps, state.RFSteps, state.LastAtUS, state.LastDeltaUS, formatMacroDuration(elapsed)) ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + } + + func macroRecordingHelp(state control.MacroRecordingState) string { +diff --git a/Tools/Controller/internal/tui/model.go b/Tools/Controller/internal/tui/model.go +index 842af1d8..08da6f37 100644 +--- a/Tools/Controller/internal/tui/model.go ++++ b/Tools/Controller/internal/tui/model.go +@@ -1217,9 +1217,10 @@ func (model Model) header(snapshot control.Snapshot) string { + style = lipgloss.NewStyle().Foreground(colorGood).Bold(true) + if snapshot.Hello.IdentitySchema == native.IdentitySchemaCompact { + detail = fmt.Sprintf( +- "%s · %s build %08X · %s", ++ "%s · %s build %08X · %s · %s", + snapshot.Port.Name, snapshot.Hello.Name, snapshot.Hello.BuildHash, + snapshot.Hello.BuildStamp, ++ native.FeatureProfileName(snapshot.Hello.FeatureProfile), + ) + } else { + detail = snapshot.Port.Name + " · " + snapshot.Hello.Name +diff --git a/Tools/Controller/internal/tui/pages.go b/Tools/Controller/internal/tui/pages.go +index 65018645..211c1fdc 100644 +--- a/Tools/Controller/internal/tui/pages.go ++++ b/Tools/Controller/internal/tui/pages.go +@@ -1037,10 +1037,12 @@ func firmwareIdentity(snapshot control.Snapshot) string { + stamp = "timestamp unavailable" + } + return fmt.Sprintf( +- "hash %08X · %s · packed %08X", ++ "hash %08X · %s · packed %08X · %s · flags %02X", + snapshot.Hello.BuildHash, + stamp, + snapshot.Hello.BuildTimestamp, ++ native.FeatureProfileName(snapshot.Hello.FeatureProfile), ++ snapshot.Hello.BuildFeatures, + ) + } + if snapshot.Hello.Name != "" { +diff --git a/Tools/Controller/web/src/advanced-workbench.tsx b/Tools/Controller/web/src/advanced-workbench.tsx +index eea74353..92b65e50 100644 +--- a/Tools/Controller/web/src/advanced-workbench.tsx ++++ b/Tools/Controller/web/src/advanced-workbench.tsx +@@ -403,6 +403,16 @@ export function AdvancedWorkbench({ + ...(macroCategory.trim() ? [quoteArgument(macroCategory.trim())] : []), + ...(macroColor.trim() ? [quoteArgument(macroColor.trim())] : []), + ].join(' ') ++<<<<<<< HEAD ++======= ++ const macroUpdateCommand = [ ++ 'macro update', ++ quoteArgument(macroRef.trim()), ++ quoteArgument(macroName.trim()), ++ quoteArgument(macroCategory.trim() || '-'), ++ quoteArgument(macroColor.trim() || '-'), ++ ].join(' ') ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) + const messageByteLength = new TextEncoder().encode(messageText.trim()).byteLength + const lcdMessageValid = Boolean(messageLine1 || messageLine2) && + messageLine1.length <= 16 && messageLine2.length <= 16 && +@@ -694,8 +704,10 @@ export function AdvancedWorkbench({ +
+ + ++ + + ++ +
+ + +diff --git a/Tools/Controller/web/src/types.ts b/Tools/Controller/web/src/types.ts +index a8437d17..fd291edb 100644 +--- a/Tools/Controller/web/src/types.ts ++++ b/Tools/Controller/web/src/types.ts +@@ -35,6 +35,8 @@ export interface Hello { + build_date?: string + build_time?: string + build_timestamp?: string ++ feature_profile?: number ++ build_features?: number + } + + export interface ControllerStatus { +diff --git a/Tools/Controller/web/src/views.tsx b/Tools/Controller/web/src/views.tsx +index 700087e9..5ba19ce8 100644 +--- a/Tools/Controller/web/src/views.tsx ++++ b/Tools/Controller/web/src/views.tsx +@@ -176,6 +176,12 @@ export function DashboardView(props: SharedViewProps) { + const status = snapshot.status + const connectedTone = snapshot.connected ? 'good' : snapshot.paused ? 'warn' : 'bad' + const hash = snapshot.hello.build_hash ? snapshot.hello.build_hash.toString(16).toUpperCase().padStart(8, '0') : '—' ++ const featureProfiles = ['full-peripheral', 'motion-macro', 'key-diagnostic', 'custom'] ++ const featureProfileID = snapshot.hello.feature_profile ++ const featureProfile = featureProfileID === undefined ? '—' : featureProfiles[featureProfileID] || `profile-${featureProfileID}` ++ const buildFeatures = snapshot.hello.build_features === undefined ++ ? '—' ++ : `0x${snapshot.hello.build_features.toString(16).toUpperCase().padStart(2, '0')}` + const activeRelayCount = Array.from({ length: 8 }, (_, index) => Boolean(status.active_relays & (1 << index))).filter(Boolean).length + const configurationEventID = events.find((event) => event.kind === 'config')?.id ?? 0 + const [hostUI, setHostUI] = useState(null) +@@ -278,6 +284,8 @@ export function DashboardView(props: SharedViewProps) { +
+ + ++ ++ + + + +diff --git a/docs/Front-Panel-and-Menus.md b/docs/Front-Panel-and-Menus.md +index 9a1360ff..987ea700 100644 +--- a/docs/Front-Panel-and-Menus.md ++++ b/docs/Front-Panel-and-Menus.md +@@ -353,6 +353,16 @@ starts. Status RGB is channels 13-15. EEPROM factory brightness is 128. + Animated levels change by four every 20 ms, and informational transitions ease + toward their new color instead of inserting a black or unrelated frame. + ++Production full-peripheral builds omit the 1,918-byte local status-effect ++engine to remain inside the ATmega328P application limit. They still drive the ++critical PCA9685 Power/On signal directly on channel 12; the connected Go host ++owns RGB channels 13-15 through the ordinary RGB opcode. The table below ++describes the optional status-engine feature profile, not offline production ++behavior. Local boot/fault animations are therefore unavailable without the ++host. A tiny fixed critical offline indicator remains tracked in ++[#21](https://github.com/atomicdeploy/PCController/issues/21); it must fit the ++proven stack/flash margin before being enabled. ++ + | Priority/state | RGB behavior | + |---|---| + | Programming latch | Power indicator and RGB remain off until the host completes verify/reconnect/restore | +@@ -920,8 +930,9 @@ protocol and host: + display, menu, reset, and generic I2C commands; + - graceful reboot sequence that stops the buzzer, RF momentary action, motion, + relays, and user PWM outputs while playing the Reset RGB cue; +-- boot melody and boot/status RGB indication; door, BT Audio, RF, navigation, +- save/discard, warning/hot, fault, and reset RGB cues; ++- boot melody plus direct PCA9685 Power/On indication; when the host is ++ connected it supplies door, BT Audio, RF, navigation, save/discard, ++ warning/hot, fault, and reset RGB cues through the native opcode; + - door and relay audio enable flags plus global Silent mode in EEPROM; + - two DS18B20 ROM identities, role swap setting, asynchronous 11-bit + conversion, and temperature smoothing; OneWire work pauses during RF learn; +diff --git a/docs/Host-Configuration-and-Integrations.md b/docs/Host-Configuration-and-Integrations.md +index 4c185520..c61f582b 100644 +--- a/docs/Host-Configuration-and-Integrations.md ++++ b/docs/Host-Configuration-and-Integrations.md +@@ -323,11 +323,53 @@ Playback reads the same `MacroRunner` instance used by shell, IPC, and API + commands. The page therefore reports live macro identity, elapsed/duration, + step progress, MCU circular-buffer fill out of 127 bytes, accepted bytes, + last/maximum device timing delta, configured tolerance, violations, underruns, ++<<<<<<< HEAD + dispatch errors, lifecycle, and final faithfulness. Recording offsets come from + MCU acknowledgement timestamps, not variable USB/network arrival time. Macro + definitions remain PC configuration; only the active timing queue occupies AVR + RAM. The deterministic preview contains a safe representative library for UI + inspection and never opens serial. ++======= ++dispatch errors, dropped/missing capture evidence, lifecycle, and final ++faithfulness. Recording offsets come from one typed MCU-clock evidence stream: ++acknowledged host operations and successful physical/RF actions carry the same ++ordinary opcode/payload shape. Source=HOST event echoes are deduplicated against ++their authoritative ACK, and USB/network arrival time is never used as the ++recording clock. ++ ++`macro record status` exposes `last_at_us` and `last_delta_us` while recording. ++After save or board recovery, `macro show NAME_OR_ID` prints the exact integer ++`at_us` offset from the capture epoch and `delta_us` from the preceding action ++for every step. The TUI uses the same state and the Web workbench calls these ++same commands, so none of the three surfaces substitutes host arrival time. ++ ++Pressing Record on the board opens provisional PC metadata named ++`Board capture N` in category `board`. The schema-3 MCU ring retains a strict ++no-overwrite prefix and exposes it in bounded 40-byte recovery pages; a ++continuously connected host also retains the final action that seals a full ++ring. Save records `capture_dropped_steps` and `capture_missing_steps`, so an ++offline/truncated recovery can never masquerade as complete. `macro update` ++and the TUI/Web edit action rename or recategorize the result without changing ++its exact steps. Macro definitions remain PC configuration; only the active ++timing/capture ring occupies AVR RAM. The deterministic preview contains a ++safe representative library, forces every buzzer path to a state-only/muted ++double, and never opens serial or energizes physical outputs. ++>>>>>>> e2958b6 (feat(macros): recover timed capture and safe schema migration) ++ ++The host writes a board-identity/capture-epoch/stream SHA-256 import key into ++configuration before sending the identity-guarded export acknowledgement. ++The board deliberately retains acknowledged bytes for replay and inspection; ++reconnecting to an `Exported` capture acknowledges the same durable import ++without creating another macro. Capture pages, acknowledgement, playback, and ++cancel requests are pinned to one authenticated connection generation, so a ++replacement board can never inherit an in-flight operation. ++ ++Current alpha firmware identifies itself with strict 16-byte HELLO schema 4. ++Its profile (`full-peripheral`, `motion-macro`, `key-diagnostic`, or `custom`) ++and one-byte build-feature mask are displayed by CLI, TUI, and Web UI. The ++current production/default profile is `full-peripheral`; it keeps INA219, ++PCA9685, DS18B20, LCD, local macro capture, status LED, and illumination ++support. Schema 3 is intentionally not treated as compatible during alpha. + + The same library is now available as the default HOST-presented physical + `MACR` submenu. Its selector is rebuilt from the watched macro array and sorted +@@ -690,12 +732,13 @@ Tools\Controller\bin\controller.exe program --operation read-eeprom --method urc + Tools\Controller\bin\controller.exe program --operation write-eeprom --method urclock --port COM18 --hex EEPROM-FACTORY.hex --confirm-eeprom-write + ``` + +-The current settings artifact is exactly 40 settings/name value bytes plus +-CRC-8 at EEPROM addresses `0x0020..0x0048`. Export emits only that sparse +-region. Import ++The production settings artifact is exactly 22 controller bytes, one name ++length byte, eight name bytes, and CRC-8 at EEPROM addresses ++`0x0020..0x003F`. Export emits only that sparse 32-byte region. Import + overlays it on the validated 1,024-byte backup so RF records, reset journal, +-and every unknown byte remain unchanged; restore reproduces the original full +-EEPROM image. Outputs are hashed and never overwritten. These commands do not ++and every unrelated byte remain unchanged. A retired 41-byte menu-layout ++record is decoded only for explicit semantic conversion; its raw tail is erased ++instead of restored. Outputs are hashed and never overwritten. These commands do not + open serial or write a device; an actual EEPROM write and readback remain + separate, explicit programming operations. + +diff --git a/docs/Requirements-Backlog.md b/docs/Requirements-Backlog.md +index acd842cc..5df8e03e 100644 +--- a/docs/Requirements-Backlog.md ++++ b/docs/Requirements-Backlog.md +@@ -28,7 +28,7 @@ This is the canonical public map from normalized project requirements to GitHub + |---|---:|:---:|---| + | `board-pin-map-inputs` | [#19](https://github.com/atomicdeploy/PCController/issues/19) | 🟡 open | Validate the complete board pin, shift-register, BT Audio, and reed mapping | + | `measurement-sensors-i2c` | [#20](https://github.com/atomicdeploy/PCController/issues/20) | 🟡 open | Deliver stable INA219 and dual-DS18B20 measurements with conflict-free I2C discovery | +-| `pwm-lighting-rgb-strip` | [#21](https://github.com/atomicdeploy/PCController/issues/21) | 🟡 open | Complete PWM ownership, enclosure fade, status RGB, power light, and addressable strip behavior | ++| `pwm-lighting-rgb-strip` | [#21](https://github.com/atomicdeploy/PCController/issues/21) | 🟡 open | Complete PWM ownership, host-driven status RGB, direct PCA channel-12 power light, addressable strip behavior, and a flash-safe tiny fixed boot/fault indicator follow-up | + | `displays-audio` | [#22](https://github.com/atomicdeploy/PCController/issues/22) | 🟡 open | Finish smooth TM1637, optional LCD, buzzer, melody, and configurable cue behavior | + | `cooperative-host-i2c-profile` | [#23](https://github.com/atomicdeploy/PCController/issues/23) | ✅ closed | Measure and implement the cooperative host-driven I2C/LCD profile | + +diff --git a/docs/Toolchain-and-Safe-Programming.md b/docs/Toolchain-and-Safe-Programming.md +index a7707b05..cbcc71e6 100644 +--- a/docs/Toolchain-and-Safe-Programming.md ++++ b/docs/Toolchain-and-Safe-Programming.md +@@ -270,13 +270,13 @@ reasserts relay/PWM-off, the safe EEPROM image, and `Prog`; it deliberately does + not resume illumination or audio. Once the programmer has recorded its final + outcome, the next connection finishes exact restoration and clears the marker. + +-### Development EEPROM reinitialization ++### Development EEPROM semantic migration + +-`--reinitialize-eeprom` is a deliberately destructive, host-tool-only escape +-hatch for an unpublished development board whose `GET_SETTINGS` payload no +-longer matches the current host schema. It does **not** add an old settings +-decoder, migration table, or compatibility branch to the firmware. Use it only +-when losing the board's previous semantic settings is acceptable: ++`--reinitialize-eeprom` is a host-tool-only transaction for an unpublished ++development board whose raw EEPROM layout differs from the compiled layout. It ++does **not** add a compatibility branch to firmware. The host requires the ++stable semantic SETTINGS response and exact live-state capture, retains the raw ++backup, and converts only fields that exist in the new schema: + + ```console + controller program flash .build\firmware\PCController.ino.hex COM18 ^ +@@ -291,41 +291,35 @@ result. The primary remains the only process that opens the port. + The exception still requires a complete verified flash, raw EEPROM, and + programmer-metadata backup before the firmware write. It cannot be combined + with `--allow-incomplete-backup`. Before releasing UART ownership, the host +-persists the settings-query failure and any live state it could capture, +-cancels macros, releases all relays, fades PWM when possible (otherwise forces +-it off), shows the programming cues, and plays the power-down melody. It does +-not rewrite the incompatible EEPROM before that raw backup. +- +-Immediately after the untouched raw backup, Controller reconnects to a +-current-schema application when available, persists and reads back the durable +-Silent/Programming flags, restores `Prog`, and releases UART again before the +-firmware write. This post-backup boundary is part of the flash gate: failure to +-arm a supported latch prevents flashing. If the old settings schema is +-genuinely unsupported, Controller cannot safely patch unknown EEPROM bytes and +-reports that limitation; the newly written transaction image becomes the +-first safe latch point. +- +-After verified flashing, and before clearing the lifecycle marker, the host +-programs and independently reads back a complete generated 1,024-byte +-**transaction** EEPROM image. It contains the canonical Go-owned settings, +-empty RF store, rich status-LED profiles, and an armed Silent/Programming latch +-with visible `Prog`. Every verification/reconnect reset after this write +-therefore remains quiet and output-safe. Only after the new authenticated +-`HELLO` does the host query the current schema, commit and verify ordinary +-canonical settings with Silent and Programming cleared and all relay/PWM +-persistence disabled, then play the single configurable ready cue. It never +-maps or restores old semantic values or live outputs. The original raw EEPROM +-remains recoverable from the pre-flash backup, but restoring it would +-deliberately reintroduce the incompatible development state. ++captures settings and live state, cancels macros, releases all relays, fades ++PWM, shows the programming cues, and plays the power-down melody. A failed ++semantic capture stops the transaction. It does not rewrite EEPROM before the ++untouched raw backup. ++ ++Immediately after the raw backup, Controller reconnects, persists and reads ++back the old application's durable Silent/Programming flags, then releases ++UART. It semantically converts the validated backup to production schema 1 ++(22 controller bytes plus name envelope and CRC-8), preserves RF/status/reset ++and unknown regions, erases the retired menu-layout tail, forces outputs off, ++arms Silent/Programming, and programs this complete image **before** firmware ++flash. Failure at any step prevents flashing. ++ ++Every reset during flash therefore remains quiet, output-safe, and visibly ++latched at `Prog`. Only after the new authenticated `HELLO` does the host stage ++the captured semantic settings behind Programming, verify them, clear ++Programming as the final settings write, and restore the captured live state. ++The original raw EEPROM remains recoverable from the verified backup; it is ++never written directly across layouts. + + ### Alpha version and feature-profile policy + + During alpha, a newer **version build** may replace the current wire, flash, or + EEPROM layout directly. Do not add dual readers, migrations, compatibility +-aliases, or preservation logic merely to accept an earlier alpha version. Keep +-the mandatory raw backup, reject the obsolete semantic layout, and use the +-explicit reinitialization transaction when the old values are intentionally +-discarded. ++aliases, or preservation logic to normal firmware merely to accept an earlier ++alpha version. Keep the mandatory raw backup and use the explicit host ++transaction for a one-time conversion of shared semantic fields. That bounded ++converter must not become a permanent live protocol/version compatibility ++chain. + + Compatibility and preservation are required only when distinct + **profile/feature builds are intentionally supported at the same time**. Each diff --git a/docs/recovery/motion-macro-checkpoints-20260824/README.md b/docs/recovery/motion-macro-checkpoints-20260824/README.md new file mode 100644 index 000000000..2faf8f954 --- /dev/null +++ b/docs/recovery/motion-macro-checkpoints-20260824/README.md @@ -0,0 +1,60 @@ +# Motion and macro recovery checkpoints + +This directory preserves two historical August 2026 checkpoints that were once +published as temporary recovery branches. Those branch refs were later removed, +while the underlying commits remained available. The patches make the work +durable and reviewable from a branch based on current `main` without importing +the obsolete branch histories. + +These patches are preservation evidence, not merge-ready changes. Reconcile +their behavior against current `main`, issue #44, and issue #554 before porting +any implementation. + +## Firmware motion and macro checkpoint + +- Original commit: `3caedc92f20a8b1544583c643365fa826bcb93ad` +- Original parent: `c4fa2521b763b547269daaf91a74cc3d5a316255` +- Patch: `0001-checkpoint-preserve-firmware-motion-macro-work.patch` +- Patch SHA-256: `9E168EAE11EBC1FA096DF3012BC42AE2375655E625491B8034B37C7A0223D460` +- Recovery verification: all 29 changed paths still present in the orphaned + Windows worktree matched this commit byte-for-byte after Git clean filters. + The thirtieth diff path is the source side of a recorded rename and is fully + represented by the patch. + +## Interrupted host integration checkpoint + +- Original commit: `a2e62f38cfe21de9c0c86ff564dfc824d4699ec9` +- Original parent: `1895bee5f9a8bc9e68864e00d10c18d77fb5dca8` +- Patch: `0001-checkpoint-preserve-interrupted-motion-macro-integra.patch` +- Patch SHA-256: `04C7996B6196A775BAC3F42B3AD33A1867FFDF5107078E7B88BC7539C83CE7BF` +- Recovery note: this independent 45-path snapshot intentionally preserves an + interrupted integration state and may include obsolete or conflicting design + choices. Review and port coherent slices rather than applying it wholesale. + +To reconstruct either original commit for analysis, create an isolated worktree +at its recorded parent and apply the corresponding patch with `git am --3way`. +Do not apply these patches over a dirty checkout. + +## Additional orphaned commit histories + +Three older sandbox-owned worktrees retained useful commit histories that were +uploaded to GitHub by object ID but no longer had a live remote branch or pull +request. The Git bundles in `commit-bundles/` preserve their exact commit +objects and original branch tips without importing the old histories into +current `main`. + +| Bundle | Tip | Prerequisite | Commits | SHA-256 | +|---|---|---|---:|---| +| `firmware-macro-priority-9265933.bundle` | `92659332caffecfe1d2e328399f3004f489601a8` | complete history | 7 | `3DF51C488468393F0CA22E1D38047192AFD83C8E5A1BA7D8C57D7EB0A856AD15` | +| `macro-host-resume-cc1eb8d.bundle` | `cc1eb8dc64030d24156de839bece7d3a31112c66` | `1895bee5f9a8bc9e68864e00d10c18d77fb5dca8` | 12 | `A5D604CE77AB82543451921E5649A6A80121F431B84E496A38CE4BC64E5CA6CE` | +| `autonomy-storage-docs-f859392.bundle` | `f859392421c546acdb44d9b7824d7af3c6b63c31` | `87b2c17a70e35910f67857477d7d3653b17c334d` | 2 | `20FA572EE4C38C83E0CD5C6A2BBCC2BC84376E06B1C49EE52FBCA84CECC5DC73` | + +Each bundle passes `git bundle verify`. All commits in the three preserved +ranges were scanned for strong credential markers, embedded authenticated RTSP +URLs, private-key headers, and private Windows user paths before publication; +no matches were found. + +For a prerequisite bundle, fetch its prerequisite from this repository before +fetching the bundle tip. The firmware bundle records a complete standalone +history. Review all recovered work against current requirements before porting +it; these bundles are evidence and handoff artifacts, not merge candidates. diff --git a/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/autonomy-storage-docs-f859392.bundle b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/autonomy-storage-docs-f859392.bundle new file mode 100644 index 000000000..21e25da85 Binary files /dev/null and b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/autonomy-storage-docs-f859392.bundle differ diff --git a/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/firmware-macro-priority-9265933.bundle b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/firmware-macro-priority-9265933.bundle new file mode 100644 index 000000000..6de757ce9 Binary files /dev/null and b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/firmware-macro-priority-9265933.bundle differ diff --git a/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/macro-host-resume-cc1eb8d.bundle b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/macro-host-resume-cc1eb8d.bundle new file mode 100644 index 000000000..8b8788a89 Binary files /dev/null and b/docs/recovery/motion-macro-checkpoints-20260824/commit-bundles/macro-host-resume-cc1eb8d.bundle differ