Kaynağa Gözat

first commit

Thomas Hondema 1 hafta önce
işleme
80eaaac551
80 değiştirilmiş dosya ile 5860 ekleme ve 0 silme
  1. 11 0
      .vscode/extensions.json
  2. 24 0
      README.md
  3. 445 0
      lib/meCoffee_controller_logic/src/command_protocol.cpp
  4. 16 0
      lib/meCoffee_controller_logic/src/command_protocol.h
  5. 82 0
      lib/meCoffee_controller_logic/src/diagnostic_queue.cpp
  6. 20 0
      lib/meCoffee_controller_logic/src/diagnostic_queue.h
  7. 207 0
      lib/meCoffee_controller_logic/src/firmware_runtime.cpp
  8. 17 0
      lib/meCoffee_controller_logic/src/firmware_runtime.h
  9. 160 0
      lib/meCoffee_controller_logic/src/firmware_state.h
  10. 64 0
      lib/meCoffee_controller_logic/src/hardware_seam.h
  11. 236 0
      lib/meCoffee_controller_logic/src/heater_controller.cpp
  12. 39 0
      lib/meCoffee_controller_logic/src/heater_controller.h
  13. 27 0
      lib/meCoffee_controller_logic/src/integer_arithmetic.h
  14. 64 0
      lib/meCoffee_controller_logic/src/mains_events.cpp
  15. 18 0
      lib/meCoffee_controller_logic/src/mains_events.h
  16. 151 0
      lib/meCoffee_controller_logic/src/module_provisioning.cpp
  17. 15 0
      lib/meCoffee_controller_logic/src/module_provisioning.h
  18. 325 0
      lib/meCoffee_controller_logic/src/numeric_conversion.cpp
  19. 41 0
      lib/meCoffee_controller_logic/src/numeric_conversion.h
  20. 63 0
      lib/meCoffee_controller_logic/src/oscillator_adjustment.cpp
  21. 19 0
      lib/meCoffee_controller_logic/src/oscillator_adjustment.h
  22. 127 0
      lib/meCoffee_controller_logic/src/output_channels.cpp
  23. 65 0
      lib/meCoffee_controller_logic/src/output_channels.h
  24. 209 0
      lib/meCoffee_controller_logic/src/persistent_settings_store.cpp
  25. 41 0
      lib/meCoffee_controller_logic/src/persistent_settings_store.h
  26. 122 0
      lib/meCoffee_controller_logic/src/power_schedule.cpp
  27. 27 0
      lib/meCoffee_controller_logic/src/power_schedule.h
  28. 14 0
      lib/meCoffee_controller_logic/src/program_memory.h
  29. 39 0
      lib/meCoffee_controller_logic/src/pulse_tick.h
  30. 147 0
      lib/meCoffee_controller_logic/src/sensor_sampling.cpp
  31. 31 0
      lib/meCoffee_controller_logic/src/sensor_sampling.h
  32. 56 0
      lib/meCoffee_controller_logic/src/serial_output.cpp
  33. 14 0
      lib/meCoffee_controller_logic/src/serial_output.h
  34. 113 0
      lib/meCoffee_controller_logic/src/settings_values.cpp
  35. 79 0
      lib/meCoffee_controller_logic/src/settings_values.h
  36. 219 0
      lib/meCoffee_controller_logic/src/shot_control.cpp
  37. 38 0
      lib/meCoffee_controller_logic/src/shot_control.h
  38. 60 0
      lib/meCoffee_controller_logic/src/telemetry_reports.cpp
  39. 19 0
      lib/meCoffee_controller_logic/src/telemetry_reports.h
  40. 229 0
      lib/meCoffee_hardware_access/src/arduino_hardware.cpp
  41. 47 0
      lib/meCoffee_hardware_access/src/board_profile.h
  42. 66 0
      lib/meCoffee_hardware_access/src/uno_ble_bootloader.cpp
  43. 46 0
      lib/meCoffee_simulated_hardware/src/firmware_test_support.cpp
  44. 17 0
      lib/meCoffee_simulated_hardware/src/firmware_test_support.h
  45. 142 0
      lib/meCoffee_simulated_hardware/src/simulated_hardware.cpp
  46. 57 0
      lib/meCoffee_simulated_hardware/src/simulated_hardware.h
  47. 91 0
      platformio.ini
  48. 7 0
      src/main.cpp
  49. 6 0
      tools/apply_cplusplus_flags.py
  50. BIN
      tools/orig/flash.bin
  51. 1659 0
      tools/orig/meCoffee-V10.hex
  52. 1 0
      tools/toolchain-native/bin/avr-addr2line
  53. 1 0
      tools/toolchain-native/bin/avr-ar
  54. 1 0
      tools/toolchain-native/bin/avr-as
  55. 1 0
      tools/toolchain-native/bin/avr-c++
  56. 1 0
      tools/toolchain-native/bin/avr-c++filt
  57. 1 0
      tools/toolchain-native/bin/avr-cpp
  58. 1 0
      tools/toolchain-native/bin/avr-elfedit
  59. 1 0
      tools/toolchain-native/bin/avr-g++
  60. 1 0
      tools/toolchain-native/bin/avr-gcc
  61. 1 0
      tools/toolchain-native/bin/avr-gcc-9.5.0
  62. 1 0
      tools/toolchain-native/bin/avr-gcc-ar
  63. 1 0
      tools/toolchain-native/bin/avr-gcc-nm
  64. 1 0
      tools/toolchain-native/bin/avr-gcc-ranlib
  65. 1 0
      tools/toolchain-native/bin/avr-gcov
  66. 1 0
      tools/toolchain-native/bin/avr-gcov-dump
  67. 1 0
      tools/toolchain-native/bin/avr-gcov-tool
  68. 1 0
      tools/toolchain-native/bin/avr-gprof
  69. 1 0
      tools/toolchain-native/bin/avr-ld
  70. 1 0
      tools/toolchain-native/bin/avr-ld.bfd
  71. 1 0
      tools/toolchain-native/bin/avr-man
  72. 1 0
      tools/toolchain-native/bin/avr-nm
  73. 1 0
      tools/toolchain-native/bin/avr-objcopy
  74. 1 0
      tools/toolchain-native/bin/avr-objdump
  75. 1 0
      tools/toolchain-native/bin/avr-ranlib
  76. 1 0
      tools/toolchain-native/bin/avr-readelf
  77. 1 0
      tools/toolchain-native/bin/avr-size
  78. 1 0
      tools/toolchain-native/bin/avr-strings
  79. 1 0
      tools/toolchain-native/bin/avr-strip
  80. 1 0
      tools/toolchain-native/package.json

+ 11 - 0
.vscode/extensions.json

@@ -0,0 +1,11 @@
+{
+    // See http://go.microsoft.com/fwlink/?LinkId=827846
+    // for the documentation about the extensions.json format
+    "recommendations": [
+        "platformio.platformio-ide"
+    ],
+    "unwantedRecommendations": [
+        "ms-vscode.cpptools-extension-pack",
+        "pioarduino.pioarduino-ide"
+    ]
+}

+ 24 - 0
README.md

@@ -0,0 +1,24 @@
+# meCoffee firmware
+
+PlatformIO project (ATmega328P, 8 MHz, Arduino core) that re-implements the behaviour of the
+original V10 image.
+
+## Toolchain
+
+The registry toolchain `toolchain-atmelavr@1.70300.191015` is an x86_64 build and does not run on
+Apple Silicon without Rosetta. `platformio.ini` therefore points `toolchain-atmelavr` at
+`tools/toolchain-native`, which symlinks `/opt/homebrew/bin/avr-*` (avr-gcc 9.5, avr-libc 2.1.0).
+On an Intel or Rosetta host replace that line with `toolchain-atmelavr@1.70300.191015`.
+The framework is pinned to `framework-arduino-avr@5.4.0` (Arduino core 1.8.8); the original used an
+older core, which phase 0 still has to compare in simulation.
+
+## Naming
+
+Project code uses the `meCoffee` C++ namespace, `meCoffee_` library names, and
+`MECOFFEE_` macros and environment variables. These names stay independent of firmware
+versions. The original-image simulator remains `tools/sim/v10sim.py`; its image override
+is `MECOFFEE_FLASH`.
+
+Identifiers are complete descriptive words. Persisted and on-the-wire names (`tmpsp`, `pd1p`, the
+`COMPAT_V10` build flag, oracle IDs in test names) are compatibility contracts and are kept; the
+internal members that hold them have descriptive names (for example `brewSetpoint`).

+ 445 - 0
lib/meCoffee_controller_logic/src/command_protocol.cpp

@@ -0,0 +1,445 @@
+#include "command_protocol.h"
+
+#include "diagnostic_queue.h"
+#include "hardware_seam.h"
+#include "mains_events.h"
+#include "module_provisioning.h"
+#include "numeric_conversion.h"
+#include "program_memory.h"
+#include "serial_output.h"
+#include "telemetry_reports.h"
+
+#if defined(MECOFFEE_RUST_COMMAND_READER)
+extern "C" uint16_t mecoffee_rust_accept_command_byte(uint8_t* bytes, uint8_t length, uint8_t receivedByte);
+#endif
+
+namespace meCoffee {
+
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+extern "C" void mecoffee_rust_dispatch_command(void*, const uint8_t*, uint8_t);
+void dispatchCommandLine(FirmwareState* state, char* text, uint8_t length) {
+    mecoffee_rust_dispatch_command(state, reinterpret_cast<const uint8_t*>(text), length);
+}
+#else
+namespace {
+
+constexpr uint8_t kMaximumArgumentCount = 4;
+constexpr uint8_t kCommandPrefixLength = 4;
+constexpr uint8_t kBoilerLeafRoutingDigitLimit = kOutputLeafCount;
+
+struct CommandArguments {
+    const char* values[kMaximumArgumentCount];
+};
+
+const char* argumentOrEmpty(const CommandArguments& arguments, uint8_t argumentIndex) {
+    const char* argument = arguments.values[argumentIndex];
+    return argument != nullptr ? argument : "";
+}
+
+bool areTextsEqual(const char* first, const char* second) {
+    while (*first != '\0' && *first == *second) {
+        ++first;
+        ++second;
+    }
+    return *first == *second;
+}
+
+bool startsWithBytes(const char* text, const char* expectedPrefix, uint8_t prefixLength) {
+    for (uint8_t position = 0; position < prefixLength; ++position) {
+        if (text[position] != expectedPrefix[position]) {
+            return false;
+        }
+        if (expectedPrefix[position] == '\0') {
+            return true;
+        }
+    }
+    return true;
+}
+
+bool isProgramMemoryTextEqual(const char* ramText, const char* programMemoryText) {
+    for (;; ++ramText, ++programMemoryText) {
+        const uint8_t expected = hardware::readProgramMemoryByte(programMemoryText);
+        if (static_cast<uint8_t>(*ramText) != expected) {
+            return false;
+        }
+        if (expected == 0) {
+            return true;
+        }
+    }
+}
+
+void writeSettingValueText(const FirmwareState& state, uint8_t settingIndex) {
+    writeSignedDecimalText(readSettingForDisplay(state.settings, settingIndex));
+}
+
+void reportDumpFinished(FirmwareState& state) {
+    (void)state;
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd dump "));
+    writeUnsignedDecimalText(kSettingCount);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+}
+
+void reportNextDumpedSetting(FirmwareState& state) {
+    const uint8_t settingIndex = state.diagnosticQueue.dumpCursor;
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd get "));
+    writeProgramMemoryText(settingDescriptorKeyAddress(settingIndex));
+    writeSpace();
+    writeSettingValueText(state, settingIndex);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+
+    state.diagnosticQueue.dumpCursor = static_cast<uint8_t>(settingIndex + 1);
+    if (state.diagnosticQueue.dumpCursor == kSettingCount) {
+        state.diagnosticQueue.dumpCursor = 0;
+        enqueueDiagnosticCallback(&state.diagnosticQueue, reportDumpFinished);
+    } else {
+        enqueueDiagnosticCallback(&state.diagnosticQueue, reportNextDumpedSetting);
+    }
+}
+
+void reportStoredOscillatorCalibration(FirmwareState& state) {
+    (void)state;
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd osccal = "));
+    writeSignedDecimalText(hardware::readPersistentByte(0));
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+}
+
+bool handleUname(FirmwareState*, const CommandArguments&) {
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("meCoffee  pcb V9"));
+    for (uint8_t signatureByteIndex = 0; signatureByteIndex < 6; signatureByteIndex += 2) {
+        writeSpace();
+        writeUnsignedDecimalText(hardware::readDeviceSignatureByte(signatureByteIndex));
+    }
+    return true;
+}
+
+bool handleDump(FirmwareState* state, const CommandArguments&) {
+    enqueueDiagnosticCallback(&state->diagnosticQueue, reportNextDumpedSetting);
+    return true;
+}
+
+bool handleOscillatorCalibration(FirmwareState* state, const CommandArguments&) {
+    hardware::writePersistentByte(0, hardware::readOscillatorCalibrationRegister());
+    enqueueDiagnosticCallback(&state->diagnosticQueue, reportStoredOscillatorCalibration);
+    return true;
+}
+
+bool handleBoilerOutput(FirmwareState* state, const CommandArguments& arguments) {
+    OutputGroup& boiler = groupForRole(&state->outputChannels, OutputGroupRole::Boiler);
+    const char* argument = argumentOrEmpty(arguments, 0);
+    if (areTextsEqual(argument, "on")) {
+        setGroupEnabled(&boiler, true);
+    } else if (areTextsEqual(argument, "off")) {
+        setGroupEnabled(&boiler, false);
+    } else {
+        setGroupAmplitude(&boiler, parseIntegerPrefixModulo65536(argument));
+    }
+    return true;
+}
+
+bool handleAlternateMode(FirmwareState* state, const CommandArguments& arguments) {
+    state->alternateModeSelection = static_cast<uint8_t>(parseIntegerPrefixModulo65536(argumentOrEmpty(arguments, 0)));
+    return false;
+}
+
+bool handleSetting(FirmwareState* state, const CommandArguments& arguments);
+bool handleSettingQuery(FirmwareState* state, const CommandArguments& arguments);
+
+bool handleResetPin(FirmwareState* state, const CommandArguments& arguments) {
+    constexpr uint8_t kBootloaderResetLogicalPin = 16;
+    const char* argument = argumentOrEmpty(arguments, 0);
+    hardware::configureLogicalPin(kBootloaderResetLogicalPin, hardware::PinConfiguration::Output);
+    if (argument[0] == 'e' && argument[1] == 'n') {
+        hardware::writeLogicalPin(kBootloaderResetLogicalPin, false);
+        hardware::configureLogicalPin(kBootloaderResetLogicalPin, hardware::PinConfiguration::Input);
+        state->diagnosticQueue.isReportingInhibited = 1;
+    } else {
+        hardware::writeLogicalPin(kBootloaderResetLogicalPin, true);
+        state->diagnosticQueue.isReportingInhibited = 0;
+    }
+    return true;
+}
+
+bool handleFilteredPhaseReport(FirmwareState* state, const CommandArguments&) {
+    writeUnsignedDecimalText(state->mainsPhase.filteredTicksPerHalfCycle);
+    writeCarriageReturnLineFeed();
+    return true;
+}
+
+bool handlePump(FirmwareState* state, const CommandArguments& arguments) {
+    const uint8_t amplitudeByte = static_cast<uint8_t>(parseIntegerPrefixModulo65536(argumentOrEmpty(arguments, 0)));
+    OutputGroup& pump = groupForRole(&state->outputChannels, OutputGroupRole::Pump);
+    setGroupAmplitude(&pump, amplitudeByte);
+    setGroupEnabled(&pump, amplitudeByte != 255);
+    return true;
+}
+
+bool handleClock(FirmwareState* state, const CommandArguments& arguments) {
+    const char* subcommand = argumentOrEmpty(arguments, 0);
+    const char* secondArgument = argumentOrEmpty(arguments, 1);
+    if (startsWithBytes(subcommand, "set", 3)) {
+        state->eventClock.pendingSeconds = parseIntegerPrefixModulo4294967296(secondArgument);
+        state->eventClock.isValid = 1;
+    } else if (startsWithBytes(subcommand, "get", 3)) {
+        writeUnsignedDecimalText(readEventClockSecondsAtomically(*state));
+    } else if (startsWithBytes(subcommand, "tmr", 3)) {
+        state->eventClock.timingWord = static_cast<int16_t>(parseIntegerPrefixModulo65536(secondArgument));
+    }
+    return true;
+}
+
+bool handleIntegralDebug(FirmwareState* state, const CommandArguments&) {
+    const int32_t setpoint = state->settings.brewSetpoint;
+    const int32_t measurement = state->sensor.firstChannelAggregate;
+    const uint32_t scaledDifference = static_cast<uint32_t>(setpoint - measurement) * 2500u;
+    const uint32_t divisor = static_cast<uint32_t>(setpoint);
+    const uint32_t quotient = divisor == 0 ? 0xffffffffUL : scaledDifference / divisor;
+    const uint32_t accumulator = (quotient + 1500u) * 5u;
+    state->heater.integralAccumulator = static_cast<int32_t>(accumulator);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("dbg "));
+    writeUnsignedDecimalText(state->sensor.aggregateGeneration);
+    writeSpace();
+    writeSignedDecimalText(state->heater.integralAccumulator);
+    writeCarriageReturnLineFeed();
+    return true;
+}
+
+bool handleCool(FirmwareState* state, const CommandArguments&) {
+    OutputGroup& valve = groupForRole(&state->outputChannels, OutputGroupRole::Valve);
+    OutputGroup& pump = groupForRole(&state->outputChannels, OutputGroupRole::Pump);
+    setGroupEnabled(&valve, true);
+    setGroupEnabled(&pump, true);
+    hardware::blockForMilliseconds(2000);
+    setGroupEnabled(&valve, false);
+    setGroupEnabled(&pump, false);
+    return true;
+}
+
+bool handleSensorRing(FirmwareState* state, const CommandArguments&) {
+    for (int8_t ringPosition = kSensorRingLength - 1; ringPosition >= 0; --ringPosition) {
+        writeUnsignedDecimalText(state->sensor.firstRing[ringPosition]);
+        writeCarriageReturnLineFeed();
+    }
+    return true;
+}
+
+bool handlePhasePolynomialReport(FirmwareState* state, const CommandArguments&) {
+    const uint32_t scaledPhase = static_cast<uint32_t>(state->mainsPhase.filteredTicksPerHalfCycle) * 195u;
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("poly "));
+    writeUnsignedDecimalText(state->sensor.aggregateGeneration);
+    writeSpace();
+    writeUnsignedDecimalText(static_cast<uint16_t>(scaledPhase / 1608u));
+    writeCarriageReturnLineFeed();
+    return true;
+}
+
+bool handleSettingQuery(FirmwareState* state, const CommandArguments& arguments) {
+    const char* key = argumentOrEmpty(arguments, 0);
+    if (startsWithBytes(key, "btname", 6)) {
+        if (state->module.isPresent) {
+            writeRamText(state->module.capturedReply);
+        }
+        return true;
+    }
+    const int8_t settingIndex = findSettingIndexByProtocolKey(key);
+    if (settingIndex < 0) {
+        return false;
+    }
+    writeProgramMemoryText(settingDescriptorKeyAddress(static_cast<uint8_t>(settingIndex)));
+    writeSpace();
+    writeSettingValueText(*state, static_cast<uint8_t>(settingIndex));
+    return true;
+}
+
+bool handleSetting(FirmwareState* state, const CommandArguments& arguments) {
+    const char* key = argumentOrEmpty(arguments, 0);
+    const char* valueText = argumentOrEmpty(arguments, 1);
+    if (startsWithBytes(key, "btname", 6)) {
+        if (state->module.isPresent) {
+            captureRadioModuleReplyInAtMode(state);
+        }
+        return true;
+    }
+    if (key[0] == 'o' && key[1] >= '0' && key[1] <= '9') {
+        const uint8_t leafIndex = static_cast<uint8_t>(key[1] - '0');
+        if (leafIndex < kBoilerLeafRoutingDigitLimit) {
+            routeLeafToRoleCharacter(&state->outputChannels, leafIndex,
+                                     static_cast<uint8_t>(parseIntegerPrefixModulo65536(valueText)));
+        }
+    }
+    const int8_t settingIndex = findSettingIndexByProtocolKey(key);
+    if (settingIndex < 0) {
+        return false;
+    }
+    SettingDescriptor descriptor;
+    readSettingDescriptor(static_cast<uint8_t>(settingIndex), &descriptor);
+    const float parsedValue = parseDecimalFloatingPoint(valueText, nullptr);
+    uint8_t* storage = settingStorageAddress(&state->settings, static_cast<uint8_t>(settingIndex));
+    const uint32_t storedValue = (descriptor.byteWidth == 2 && descriptor.isSigned)
+        ? static_cast<uint32_t>(convertFloatToSigned32(parsedValue))
+        : convertFloatToUnsigned32(parsedValue, nullptr);
+    for (uint8_t byteIndex = 0; byteIndex < descriptor.byteWidth; ++byteIndex) {
+        storage[byteIndex] = static_cast<uint8_t>(storedValue >> (8 * byteIndex));
+    }
+    persistSetting(&state->settings, state->settingRecordHandles, static_cast<uint8_t>(settingIndex));
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("s_"));
+    writeProgramMemoryText(settingDescriptorKeyAddress(static_cast<uint8_t>(settingIndex)));
+    writeSpace();
+    writeSettingValueText(*state, static_cast<uint8_t>(settingIndex));
+    return true;
+}
+
+using CommandHandler = bool (*)(FirmwareState*, const CommandArguments&);
+
+struct CommandEntry {
+    const char* name;
+    CommandHandler handler;
+};
+
+// The registered names, in the original table order. Names live in program memory.
+const CommandEntry* commandEntryAt(uint8_t entryIndex, CommandEntry* storage) {
+    static const char kUname[] MECOFFEE_PROGRAM_MEMORY = "uname";
+    static const char kDump[] MECOFFEE_PROGRAM_MEMORY = "dump";
+    static const char kOscillatorCalibration[] MECOFFEE_PROGRAM_MEMORY = "osccal";
+    static const char kBoiler[] MECOFFEE_PROGRAM_MEMORY = "blr";
+    static const char kAlternateMode[] MECOFFEE_PROGRAM_MEMORY = "stm";
+    static const char kSet[] MECOFFEE_PROGRAM_MEMORY = "set";
+    static const char kGet[] MECOFFEE_PROGRAM_MEMORY = "get";
+    static const char kReset[] MECOFFEE_PROGRAM_MEMORY = "reset";
+    static const char kMaximumInterval[] MECOFFEE_PROGRAM_MEMORY = "maxint";
+    static const char kPump[] MECOFFEE_PROGRAM_MEMORY = "pump";
+    static const char kClock[] MECOFFEE_PROGRAM_MEMORY = "clock";
+    static const char kIntegralTerm[] MECOFFEE_PROGRAM_MEMORY = "iterm";
+    static const char kCool[] MECOFFEE_PROGRAM_MEMORY = "cool";
+    static const char kSensor[] MECOFFEE_PROGRAM_MEMORY = "sensor";
+    static const char kPolynomial[] MECOFFEE_PROGRAM_MEMORY = "poly";
+    switch (entryIndex) {
+        case 0: *storage = {kUname, handleUname}; break;
+        case 1: *storage = {kDump, handleDump}; break;
+        case 2: *storage = {kOscillatorCalibration, handleOscillatorCalibration}; break;
+        case 3: *storage = {kBoiler, handleBoilerOutput}; break;
+        case 4: *storage = {kAlternateMode, handleAlternateMode}; break;
+        case 5: *storage = {kSet, handleSetting}; break;
+        case 6: *storage = {kGet, handleSettingQuery}; break;
+        case 7: *storage = {kReset, handleResetPin}; break;
+        case 8: *storage = {kMaximumInterval, handleFilteredPhaseReport}; break;
+        case 9: *storage = {kPump, handlePump}; break;
+        case 10: *storage = {kClock, handleClock}; break;
+        case 11: *storage = {kIntegralTerm, handleIntegralDebug}; break;
+        case 12: *storage = {kCool, handleCool}; break;
+        case 13: *storage = {kSensor, handleSensorRing}; break;
+        default: *storage = {kPolynomial, handlePhasePolynomialReport}; break;
+    }
+    return storage;
+}
+
+constexpr uint8_t kRegisteredCommandCount = 15;
+
+}  // namespace
+
+void dispatchCommandLine(FirmwareState* state, char* commandLineText, uint8_t commandLineLength) {
+    if (commandLineLength < kCommandPrefixLength || !startsWithBytes(commandLineText, "cmd ", kCommandPrefixLength)) {
+        return;
+    }
+    commandLineText[commandLineLength] = '\0';
+
+    char* cursor = commandLineText + kCommandPrefixLength;
+    char* commandName = nullptr;
+    CommandArguments arguments = {};
+    uint8_t argumentCount = 0;
+    bool isFirstToken = true;
+    while (*cursor != '\0') {
+        while (*cursor == ' ') {
+            ++cursor;
+        }
+        if (*cursor == '\0') {
+            break;
+        }
+        char* tokenStart = cursor;
+        while (*cursor != '\0' && *cursor != ' ') {
+            ++cursor;
+        }
+        if (*cursor == ' ') {
+            *cursor = '\0';
+            ++cursor;
+        }
+        if (isFirstToken) {
+            commandName = tokenStart;
+            isFirstToken = false;
+        } else if (argumentCount < kMaximumArgumentCount) {
+            arguments.values[argumentCount++] = tokenStart;
+        }
+    }
+    if (commandName == nullptr) {
+        return;
+    }
+
+#if defined(MECOFFEE_UNO_BLE_TEST)
+    // Separate opt-in command: preserve the original "cmd reset en" behavior.
+    if (areTextsEqual(commandName, "bootloader")) {
+        if (argumentCount == 0) {
+            writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd bootloader uno-test optiboot44 57600 OK\r\n"));
+        } else if (argumentCount == 1 && areTextsEqual(arguments.values[0], "enter")) {
+            writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd bootloader ready 57600 OK\r\n"));
+            hardware::enterUnoBleTestBootloader();
+        } else {
+            writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd bootloader NOT OK\r\n"));
+        }
+        return;
+    }
+#endif
+
+    for (uint8_t entryIndex = 0; entryIndex < kRegisteredCommandCount; ++entryIndex) {
+        CommandEntry entryStorage;
+        const CommandEntry* entry = commandEntryAt(entryIndex, &entryStorage);
+        if (!isProgramMemoryTextEqual(commandName, entry->name)) {
+            continue;
+        }
+        writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("cmd "));
+        writeRamText(commandName);
+        writeSpace();
+        const bool didSucceed = entry->handler(state, arguments);
+        if (didSucceed) {
+            writeSpace();
+        } else {
+            writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" NOT "));
+        }
+        writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("OK\r\n"));
+        return;
+    }
+}
+
+#endif
+
+void acceptReceivedByte(FirmwareState* state, uint8_t receivedByte) {
+#if defined(MECOFFEE_RUST_COMMAND_READER)
+    static_assert(kCommandLineCapacity == 31, "Rust command buffer capacity must match");
+    // Rust borrows only the 31 payload bytes for this call. Dispatch can mutate
+    // the buffer and append its NUL only after the Rust borrow has ended.
+    const uint16_t result = mecoffee_rust_accept_command_byte(
+        reinterpret_cast<uint8_t*>(state->commandLine), state->commandLineLength, receivedByte);
+    if ((result & 0x100u) != 0) {
+        state->commandLineLength = 0;
+        dispatchCommandLine(state, state->commandLine, static_cast<uint8_t>(result));
+    } else {
+        state->commandLineLength = static_cast<uint8_t>(result);
+    }
+#else
+    if (receivedByte == 0) {
+        return;
+    }
+    if (receivedByte == '\n') {
+        const uint8_t completedLength = state->commandLineLength;
+        state->commandLineLength = 0;
+        dispatchCommandLine(state, state->commandLine, completedLength);
+        return;
+    }
+    if (state->commandLineLength < kCommandLineCapacity) {
+        state->commandLine[state->commandLineLength++] = static_cast<char>(receivedByte);
+    }
+#endif
+}
+
+}  // namespace meCoffee

+ 16 - 0
lib/meCoffee_controller_logic/src/command_protocol.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Feeds one received byte to the line reader. NUL is ignored, LF ends the line
+// and dispatches it when it starts with `cmd `.
+void acceptReceivedByte(FirmwareState* state, uint8_t receivedByte);
+
+// Dispatches one complete line (no LF). Exposed for host tests.
+void dispatchCommandLine(FirmwareState* state, char* commandLineText, uint8_t commandLineLength);
+
+}  // namespace meCoffee

+ 82 - 0
lib/meCoffee_controller_logic/src/diagnostic_queue.cpp

@@ -0,0 +1,82 @@
+#include "diagnostic_queue.h"
+
+#include "program_memory.h"
+#include "serial_output.h"
+
+namespace meCoffee {
+#if defined(MECOFFEE_RUST_DIAGNOSTICS)
+extern "C" {
+void mecoffee_rust_enqueue_callback(DiagnosticQueue*,DiagnosticCallback);
+void mecoffee_rust_enqueue_flash(DiagnosticQueue*,const char*);
+uint8_t mecoffee_rust_service_queue(DiagnosticQueue*,void*,uint32_t);
+}
+static_assert(sizeof(DiagnosticQueue)==38,"Rust queue ABI");
+void enqueueDiagnosticCallback(DiagnosticQueue* q,DiagnosticCallback callback) { mecoffee_rust_enqueue_callback(q,callback); }
+void enqueueDiagnosticProgramMemoryText(DiagnosticQueue* q,const char* text) { mecoffee_rust_enqueue_flash(q,text); }
+bool serviceDiagnosticQueue(FirmwareState* s,uint32_t now) {
+    auto& queue=s->diagnosticQueue;
+    // Avoid a cross-language call on the hot, not-due/empty path. The Rust
+    // service rechecks these conditions before consuming a slot.
+    if(queue.isReportingInhibited || queue.nextServiceDeadline>=now) return false;
+    const auto& slot=queue.slots[queue.consumerIndex];
+    if(!slot.callback && !slot.ramText && !slot.programMemoryText) return false;
+    return mecoffee_rust_service_queue(&queue,s,now)!=0;
+}
+extern "C" void mecoffee_queue_ram_text(const char* text) { writeRamText(text); }
+extern "C" void mecoffee_queue_tail() { writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK\r\n")); }
+#else
+
+
+namespace {
+
+void advanceIndex(uint8_t* queueIndex) { *queueIndex = static_cast<uint8_t>((*queueIndex + 1) % kDiagnosticSlotCount); }
+
+void writeTextReportTail() {
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK\r\n"));
+}
+
+}  // namespace
+
+void enqueueDiagnosticCallback(DiagnosticQueue* queue, DiagnosticCallback callback) {
+    DiagnosticSlot& slot = queue->slots[queue->producerIndex];
+    slot.ramText = nullptr;
+    slot.callback = callback;
+    advanceIndex(&queue->producerIndex);
+}
+
+void enqueueDiagnosticProgramMemoryText(DiagnosticQueue* queue, const char* programMemoryText) {
+    DiagnosticSlot& slot = queue->slots[queue->producerIndex];
+    slot.callback = nullptr;
+    slot.programMemoryText = programMemoryText;
+    advanceIndex(&queue->producerIndex);
+}
+
+bool serviceDiagnosticQueue(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    DiagnosticQueue& queue = state->diagnosticQueue;
+    if (queue.isReportingInhibited != 0 || !(queue.nextServiceDeadline < foregroundSnapshotMilliseconds)) {
+        return false;
+    }
+
+    const DiagnosticSlot selectedSlot = queue.slots[queue.consumerIndex];
+    if (selectedSlot.callback != nullptr) {
+        selectedSlot.callback(*state);
+        queue.slots[queue.consumerIndex].callback = nullptr;
+    } else if (selectedSlot.ramText != nullptr) {
+        writeRamText(selectedSlot.ramText);
+        writeTextReportTail();
+        queue.slots[queue.consumerIndex].ramText = nullptr;
+    } else if (selectedSlot.programMemoryText != nullptr) {
+        writeProgramMemoryText(selectedSlot.programMemoryText);
+        writeTextReportTail();
+        queue.slots[queue.consumerIndex].programMemoryText = nullptr;
+    } else {
+        return false;
+    }
+
+    advanceIndex(&queue.consumerIndex);
+    queue.nextServiceDeadline = foregroundSnapshotMilliseconds + kDiagnosticServiceSpacing;
+    return true;
+}
+
+#endif
+}  // namespace meCoffee

+ 20 - 0
lib/meCoffee_controller_logic/src/diagnostic_queue.h

@@ -0,0 +1,20 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Callback enqueue keeps any program-memory text pointer and clears the RAM text.
+void enqueueDiagnosticCallback(DiagnosticQueue* queue, DiagnosticCallback callback);
+
+// Text enqueue keeps any RAM text pointer and clears the callback.
+void enqueueDiagnosticProgramMemoryText(DiagnosticQueue* queue, const char* programMemoryText);
+
+// Serves at most one slot. Requires reporting not inhibited and the stored
+// deadline strictly before `foregroundSnapshotMilliseconds`. Returns true when a
+// slot was served.
+bool serviceDiagnosticQueue(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds);
+
+}  // namespace meCoffee

+ 207 - 0
lib/meCoffee_controller_logic/src/firmware_runtime.cpp

@@ -0,0 +1,207 @@
+#include "firmware_runtime.h"
+
+#include <string.h>
+
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+#include <stddef.h>
+#include <avr/io.h>
+#include <avr/interrupt.h>
+#endif
+
+#include "command_protocol.h"
+#include "diagnostic_queue.h"
+#include "hardware_seam.h"
+#include "program_memory.h"
+#include "heater_controller.h"
+#include "mains_events.h"
+#include "module_provisioning.h"
+#include "oscillator_adjustment.h"
+#include "power_schedule.h"
+#include "sensor_sampling.h"
+#include "shot_control.h"
+#include "telemetry_reports.h"
+
+namespace meCoffee {
+
+namespace {
+
+constexpr uint32_t kSerialBaudRate = 57600;
+constexpr uint8_t kBootloaderResetLogicalPin = 16;
+constexpr uint8_t kInitialEventFractionPeriod = 50;
+
+void routeOutputsFromSettings(FirmwareState* state) {
+    initializeOutputChannels(&state->outputChannels);
+    routeLeafToRoleCharacter(&state->outputChannels, 0, static_cast<uint8_t>(state->settings.outputZeroRoleCode));
+    routeLeafToRoleCharacter(&state->outputChannels, 1, static_cast<uint8_t>(state->settings.outputOneRoleCode));
+    routeLeafToRoleCharacter(&state->outputChannels, 2, static_cast<uint8_t>(state->settings.outputTwoRoleCode));
+}
+
+}  // namespace
+
+FirmwareState firmwareStateStorage;
+
+void initializeAtConstruction() {
+    FirmwareState& state = firmwareState();
+    for (uint8_t leafIndex = 0; leafIndex < kOutputLeafCount; ++leafIndex) {
+        // The original constructs the leaves for pins 10, 3 and 9, in that order.
+        const uint8_t logicalPin = kLogicalPinForLeaf[leafIndex == 0 ? 1 : (leafIndex == 1 ? 0 : 2)];
+        hardware::configureLogicalPin(logicalPin, hardware::PinConfiguration::Output);
+        hardware::writeLogicalPin(logicalPin, false);
+    }
+    hardware::configureLogicalPin(8, hardware::PinConfiguration::InputWithPullUp);
+    hardware::configureLogicalPin(4, hardware::PinConfiguration::InputWithPullUp);
+    hardware::configureLogicalPin(5, hardware::PinConfiguration::InputWithPullUp);
+
+    ensurePersistentStoreIsInitialized();
+    registerAndRestoreSettings(&state.settings, state.settingRecordHandles);
+}
+
+void startFirmware() {
+    FirmwareState& state = firmwareState();
+
+    hardware::beginSerialLink(kSerialBaudRate);
+
+    hardware::configureLogicalPin(kBootloaderResetLogicalPin, hardware::PinConfiguration::Output);
+    hardware::writeLogicalPin(kBootloaderResetLogicalPin, true);
+
+    provisionRadioModuleIfNeeded(&state);
+
+    routeOutputsFromSettings(&state);
+    state.eventClock.fractionPeriod = kInitialEventFractionPeriod;
+    state.oscillatorBaselineByte = hardware::readOscillatorCalibrationRegister();
+
+    // On the supported processor the signature byte is not 0x14, so range
+    // maintenance stays enabled and both ranges start at zero.
+    const uint8_t initialRangeFlag = hardware::readDeviceSignatureByte(4) == 0x14 ? 1 : 0;
+    state.sensor.isRangeMaintenanceDisabled = initialRangeFlag;
+    state.sensor.firstRangeFlag = initialRangeFlag;
+    state.sensor.secondRangeFlag = initialRangeFlag;
+
+    initializeDebouncedInput(&state.powerInput);
+    initializeDebouncedInput(&state.shotInput);
+    initializeDebouncedInput(&state.modeInput);
+    initializePowerSchedule(&state);
+    initializeHeaterController(&state, hardware::readMillisecondCounter());
+    hardware::startPulseTimerAndMainsInterrupt();
+    initializeSensorSampling(&state);
+
+    writeBootTemperatureFrame(state);
+}
+
+namespace {
+
+constexpr uint32_t kAggregateServiceInterval = 1000;
+constexpr uint32_t kInputPollIntervalMicroseconds = 1150;
+constexpr uint32_t kPowerLostElapsedThreshold = 101;
+constexpr uint32_t kGenerationsPerOscillatorAdjustment = 8;
+
+void reportPowerLossIfMainsStopped(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    const uint32_t lastEventTimestamp = readLastEventTimestampAtomically(*state);
+    const bool isMainsSilent = lastEventTimestamp != 0 &&
+                               static_cast<uint32_t>(foregroundSnapshotMilliseconds - lastEventTimestamp) >= kPowerLostElapsedThreshold;
+    // One report per start: the original never clears its flag.
+    if (isMainsSilent && !state->isPowerLostReported) {
+        state->isPowerLostReported = true;
+        enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue, MECOFFEE_PROGRAM_TEXT("inf Power lost"));
+    }
+}
+
+void runAggregateServiceWhenDue(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    SensorState& sensor = state->sensor;
+    ++sensor.foregroundLoopCount;
+    if (static_cast<uint32_t>(foregroundSnapshotMilliseconds - sensor.aggregateServiceBaselineMilliseconds) < kAggregateServiceInterval) {
+        return;
+    }
+    sensor.aggregateServiceBaselineMilliseconds = hardware::readMillisecondCounter();
+
+    hardware::disableInterrupts();
+    state->qualifiedEventCountLastGeneration = state->mainsPhase.qualifiedEventCount;
+    state->mainsPhase.qualifiedEventCount = 0;
+    hardware::enableInterrupts();
+
+    const uint32_t previousGeneration = sensor.aggregateGeneration;
+    hardware::writeGenerationParityPin((previousGeneration & 1) != 0);
+    sensor.firstChannelAggregate = static_cast<int16_t>(aggregateSensorRing(state, sensor.firstRing, 0));
+    if (state->settings.secondSensorChannelEnabled != 0) {
+        sensor.secondChannelAggregate = static_cast<int16_t>(aggregateSensorRing(state, sensor.secondRing, 1));
+    }
+    if (previousGeneration < 2 && sensor.firstChannelAggregate < state->settings.brewSetpoint) {
+        resetIntegralAccumulator(state);
+    }
+    sensor.foregroundLoopCount = 0;
+    ++sensor.aggregateGeneration;
+
+    if (sensor.aggregateGeneration % kGenerationsPerOscillatorAdjustment == 0) {
+        const OscillatorAdjustmentResult adjustment = adjustOscillator(
+            state->qualifiedEventCountLastGeneration, state->oscillatorOffset, state->oscillatorBaselineByte,
+            state->eventClock.fractionPeriod);
+        state->oscillatorOffset = adjustment.newOffset;
+        state->eventClock.fractionPeriod = adjustment.newFractionPeriod;
+        if (adjustment.shouldWriteCalibration) {
+            hardware::writeOscillatorCalibrationRegister(adjustment.calibrationValue);
+        }
+    }
+    state->isTemperatureReportDue = true;
+}
+
+}  // namespace
+
+void runForegroundIteration() {
+    FirmwareState& state = firmwareState();
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+    static_assert(offsetof(FirmwareState, foregroundSnapshotMilliseconds) == 0x24f,
+                  "timestamp publication probe layout");
+    // INT0 copies this four-byte foreground timestamp. Publishing a partial
+    // millisecond rollover can otherwise produce a spurious power-loss report.
+    const uint32_t now = hardware::readMillisecondCounter();
+    const uint8_t saved = SREG;
+    cli();
+    state.foregroundSnapshotMilliseconds = now;
+    SREG = saved;
+#else
+    state.foregroundSnapshotMilliseconds = hardware::readMillisecondCounter();
+#endif
+    const uint32_t foregroundSnapshotMilliseconds = state.foregroundSnapshotMilliseconds;
+
+    reportPowerLossIfMainsStopped(&state, foregroundSnapshotMilliseconds);
+    consumeFinishedConversion(&state);
+    updateControllerPeriodTimer(&state, foregroundSnapshotMilliseconds);
+    runAggregateServiceWhenDue(&state, foregroundSnapshotMilliseconds);
+
+    // The original examines its inputs once per foreground pass (about 1.25 ms). Polling at a similar
+    // interval (tuned in simulation: blinks of 50 ms register in 23 of 25 phases, the original's 22)
+    // keeps edge and silence detection on the same granularity.
+    const uint32_t microsecondsNow = hardware::readMicrosecondCounter();
+    if (static_cast<uint32_t>(microsecondsNow - state.lastInputPollMicroseconds) >= kInputPollIntervalMicroseconds) {
+        state.lastInputPollMicroseconds = microsecondsNow;
+        pollDebouncedInput(&state, &state.powerInput, InputRole::Power, hardware::readLogicalPin(kPowerInputLogicalPin),
+                           foregroundSnapshotMilliseconds);
+        if (state.powerSchedule.powerState != 0) {
+            pollDebouncedInput(&state, &state.shotInput, InputRole::Shot, hardware::readLogicalPin(kShotInputLogicalPin),
+                               foregroundSnapshotMilliseconds);
+            pollDebouncedInput(&state, &state.modeInput, InputRole::Mode, hardware::readLogicalPin(kModeInputLogicalPin),
+                               foregroundSnapshotMilliseconds);
+        }
+    }
+
+    const ScheduleDecision decision = runScheduleStages(&state, foregroundSnapshotMilliseconds);
+    if (decision.isControllerAllowed) {
+        runHeaterController(&state, foregroundSnapshotMilliseconds);
+    }
+
+    if (state.isTemperatureReportDue) {
+        state.isTemperatureReportDue = false;
+        enqueueDiagnosticCallback(&state.diagnosticQueue, reportTemperatureFrame);
+    }
+    serviceDiagnosticQueue(&state, foregroundSnapshotMilliseconds);
+
+    for (;;) {
+        const int receivedByte = hardware::readSerialByte();
+        if (receivedByte < 0) {
+            break;
+        }
+        acceptReceivedByte(&state, static_cast<uint8_t>(receivedByte));
+    }
+}
+
+}  // namespace meCoffee

+ 17 - 0
lib/meCoffee_controller_logic/src/firmware_runtime.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Work the original performs from static constructors, before the Arduino timer
+// start: output and input pins, then the persistent store and settings.
+void initializeAtConstruction();
+
+// One-time start-up, run from setup().
+void startFirmware();
+
+// One pass of the foreground service loop, run from loop().
+void runForegroundIteration();
+
+}  // namespace meCoffee

+ 160 - 0
lib/meCoffee_controller_logic/src/firmware_state.h

@@ -0,0 +1,160 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "output_channels.h"
+#include "persistent_settings_store.h"
+#include "settings_values.h"
+
+namespace meCoffee {
+
+struct FirmwareState;
+
+using DiagnosticCallback = void (*)(FirmwareState&);
+
+constexpr uint8_t kDiagnosticSlotCount = 5;
+constexpr uint32_t kDiagnosticServiceSpacing = 100;
+constexpr uint8_t kSensorRingLength = 32;
+constexpr uint8_t kCommandLineCapacity = 31;
+constexpr uint8_t kModuleCaptureCapacity = 15;
+
+// Six-byte slot on the target: callback, RAM text, program-memory text.
+struct DiagnosticSlot {
+    DiagnosticCallback callback;
+    const char* ramText;
+    const char* programMemoryText;
+};
+
+struct DiagnosticQueue {
+    DiagnosticSlot slots[kDiagnosticSlotCount];
+    uint8_t producerIndex;
+    uint8_t consumerIndex;
+    uint32_t nextServiceDeadline;
+    uint8_t isReportingInhibited;
+    uint8_t dumpCursor;
+};
+
+// Clock driven by qualified mains events (not by the millisecond counter).
+struct EventClock {
+    volatile uint32_t currentSeconds;
+    volatile uint32_t pendingSeconds;
+    volatile uint8_t isValid;
+    volatile uint8_t fractionCount;
+    uint8_t fractionPeriod;
+    int32_t timingWord;
+};
+
+struct MainsPhase {
+    volatile uint16_t ticksSinceQualifiedEvent;
+    volatile uint16_t filteredTicksPerHalfCycle;
+    volatile uint8_t qualifiedEventCount;
+    volatile uint32_t lastEventTimestamp;
+};
+
+struct SensorState {
+    uint16_t firstRing[kSensorRingLength];
+    uint16_t secondRing[kSensorRingLength];
+    uint8_t ringWriteIndex;
+    uint8_t firstRangeFlag;
+    uint8_t secondRangeFlag;
+    uint8_t isRangeMaintenanceDisabled;
+    int16_t firstChannelAggregate;
+    int16_t secondChannelAggregate;
+    uint32_t aggregateGeneration;
+    uint8_t currentChannel;
+    volatile uint8_t isConversionPending;
+    uint32_t aggregateServiceBaselineMilliseconds;
+    uint32_t foregroundLoopCount;
+    int16_t startupAggregate;
+    uint32_t lastConsumeMicroseconds;
+};
+
+// Bluetooth radio module bookkeeping. `isPresent` is set once setup has been
+// attempted, which is when the original allocates its module descriptor.
+struct HeaterState {
+    int32_t integralAccumulator;
+    uint16_t previousAggregate;
+    uint32_t shotBeginMarker;
+    uint32_t shotEndMarker;
+    uint32_t retainedLockDuration;
+    int32_t proportionalTerm;
+    int32_t integralTerm;
+    int32_t derivativeTerm;
+    int32_t feedForwardTerm;
+    uint16_t demand;
+    uint32_t periodTimerBaseline;
+    bool isPeriodElapsed;
+    uint8_t bypassSelector;
+    // Last result of the demand-to-amplitude map, which depends only on its two inputs.
+    bool hasMappedAmplitude;
+    uint16_t mappedDemand;
+    uint16_t mappedPhase;
+    uint16_t mappedAmplitude;
+};
+
+// Power, wake and sleep markers with the activity time stamp (see the scheduler
+// stages). Markers use zero as "not set".
+struct PowerSchedule {
+    uint8_t powerState;
+    uint32_t wakeMarker;
+    uint32_t sleepMarker;
+    uint32_t lastActivityTimestamp;
+    bool isControllerAllowed;
+};
+
+// Pulse-train input: an input only counts as active after at least three rising
+// and three falling edges, and it ends after 51 ms without an edge.
+struct DebouncedInput {
+    uint8_t risingEdgeCount;
+    uint8_t fallingEdgeCount;
+    bool isLastLevelHigh;
+    uint32_t beginTimestamp;
+    uint32_t endTimestamp;
+    uint32_t lastChangeTimestamp;
+};
+
+struct ShotState {
+    bool isTimeoutEventLatched;
+    uint32_t lastShotDuration;
+};
+
+struct ModuleState {
+    bool isPresent;
+    char capturedReply[kModuleCaptureCapacity + 1];
+};
+
+struct FirmwareState {
+    SettingsValues settings;
+    uint16_t settingRecordHandles[kSettingCount];
+    OutputChannels outputChannels;
+    DiagnosticQueue diagnosticQueue;
+    EventClock eventClock;
+    MainsPhase mainsPhase;
+    SensorState sensor;
+    ModuleState module;
+
+    char commandLine[kCommandLineCapacity + 1];
+    uint8_t commandLineLength;
+
+    uint8_t alternateModeSelection;
+    HeaterState heater;
+    DebouncedInput powerInput;
+    DebouncedInput shotInput;
+    DebouncedInput modeInput;
+    ShotState shot;
+    PowerSchedule powerSchedule;
+    uint32_t foregroundSnapshotMilliseconds;
+    uint8_t oscillatorBaselineByte;
+    uint16_t foregroundLoopCountPerGeneration;
+    uint8_t qualifiedEventCountLastGeneration;
+    uint32_t lastInputPollMicroseconds;
+    int16_t oscillatorOffset;
+    bool isPowerLostReported;
+    bool isTemperatureReportDue;
+};
+
+extern FirmwareState firmwareStateStorage;
+
+inline FirmwareState& firmwareState() { return firmwareStateStorage; }
+
+}  // namespace meCoffee

+ 64 - 0
lib/meCoffee_controller_logic/src/hardware_seam.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <stdint.h>
+
+// Link-time seam between the controller logic and the processor. The production
+// implementation lives in lib/meCoffee_hardware_access; host tests link
+// lib/meCoffee_simulated_hardware instead.
+namespace meCoffee::hardware {
+
+void beginSerialLink(uint32_t baudRate);
+void writeSerialByte(uint8_t byteToTransmit);
+// Returns -1 when no received byte is waiting.
+int readSerialByte();
+void waitForSerialTransmitCompletion();
+
+#if defined(MECOFFEE_UNO_BLE_TEST)
+// Bench-only, exact Optiboot image required; see docs/UNO_BLE_FLASH_TEST.md.
+[[noreturn]] void enterUnoBleTestBootloader();
+#endif
+
+uint32_t readMillisecondCounter();
+uint32_t readMicrosecondCounter();
+void blockForMilliseconds(uint32_t durationInMilliseconds);
+
+uint8_t readPersistentByte(uint16_t persistentAddress);
+void writePersistentByte(uint16_t persistentAddress, uint8_t byteToStore);
+
+// Reads one byte from a constant table that lives in program memory on the target.
+uint8_t readProgramMemoryByte(const void* programMemoryAddress);
+
+uint8_t readDeviceSignatureByte(uint8_t signatureByteIndex);
+
+enum class PinConfiguration : uint8_t { Input, InputWithPullUp, Output };
+
+void configureLogicalPin(uint8_t logicalPin, PinConfiguration configuration);
+void writeLogicalPin(uint8_t logicalPin, bool isHigh);
+bool readLogicalPin(uint8_t logicalPin);
+
+// Analog-to-digital converter used for the two temperature channels.
+void selectAnalogChannel(uint8_t channelNumber);
+void startAnalogConversion();
+bool isAnalogConversionRunning();
+uint16_t readAnalogConversionResult();
+
+// Pulse outputs: logical pins 3 (leaf 0), 10 (leaf 1), 9 (leaf 2).
+void clearPulseOutputPins();
+
+// Output pins that follow the sensor range flags (selector 0 and 1) and the
+// aggregate-generation parity.
+void writeRangeIndicatorPin(uint8_t rangeSelector, bool isHigh);
+void writeGenerationParityPin(bool isHigh);
+
+// Starts the pulse timer, the analog converter and the mains-edge interrupt, and
+// configures the range-indicator and parity output pins. Called once at start-up.
+void startPulseTimerAndMainsInterrupt();
+
+// Brief critical sections for multi-byte state shared with interrupt handlers.
+void disableInterrupts();
+void enableInterrupts();
+
+uint8_t readOscillatorCalibrationRegister();
+void writeOscillatorCalibrationRegister(uint8_t calibrationValue);
+
+}  // namespace meCoffee::hardware

+ 236 - 0
lib/meCoffee_controller_logic/src/heater_controller.cpp

@@ -0,0 +1,236 @@
+#if !defined(MECOFFEE_RUST_HEATER)
+#include "heater_controller.h"
+
+#include "diagnostic_queue.h"
+#include "integer_arithmetic.h"
+#include "numeric_conversion.h"
+#include "program_memory.h"
+#include "shot_control.h"
+#include "telemetry_reports.h"
+
+namespace meCoffee {
+
+namespace {
+
+constexpr int16_t kMinimumUsableAggregate = 100;
+constexpr uint16_t kMinimumMappedAmplitude = 80;
+
+int16_t signedSetpointForMode(const FirmwareState& state) {
+    return state.alternateModeSelection == 0 ? state.settings.brewSetpoint : state.settings.steamSetpoint;
+}
+
+int32_t lowerAccumulatorBound(const SettingsValues& settings) {
+    return signedDivide(100 * static_cast<int32_t>(settings.integralAccumulatorMinimum), settings.integralGain);
+}
+
+int32_t upperAccumulatorBound(const SettingsValues& settings) {
+    return signedDivide(100 * static_cast<int32_t>(settings.integralAccumulatorMaximum), settings.integralGain);
+}
+
+int32_t clampAccumulatorCandidate(const SettingsValues& settings, int32_t candidate) {
+    const int32_t lowerBound = lowerAccumulatorBound(settings);
+    const int32_t afterLower = candidate > lowerBound ? candidate : lowerBound;
+    const int32_t upperBound = upperAccumulatorBound(settings);
+    return afterLower < upperBound ? afterLower : upperBound;
+}
+
+uint16_t mapDemandToAmplitudeCached(HeaterState* heater, uint16_t demand, uint16_t filteredPhase) {
+    if (!heater->hasMappedAmplitude || heater->mappedDemand != demand || heater->mappedPhase != filteredPhase) {
+        heater->mappedAmplitude = mapDemandToAmplitude(demand, filteredPhase);
+        heater->mappedDemand = demand;
+        heater->mappedPhase = filteredPhase;
+        heater->hasMappedAmplitude = true;
+    }
+    return heater->mappedAmplitude;
+}
+
+void writeHeaterAmplitudeThroughWrapper(FirmwareState* state, uint16_t demand) {
+    uint16_t mappedAmplitude = mapDemandToAmplitudeCached(&state->heater, demand, state->mainsPhase.filteredTicksPerHalfCycle);
+    if (mappedAmplitude < kMinimumMappedAmplitude) {
+        mappedAmplitude = 0;
+    }
+    setGroupAmplitude(&groupForRole(&state->outputChannels, OutputGroupRole::Boiler), mappedAmplitude);
+}
+
+void reportHeaterEnableChange(FirmwareState* state, bool shouldEnable) {
+    enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue,
+                                       shouldEnable ? MECOFFEE_PROGRAM_TEXT("pid 65536 0 0 0") : MECOFFEE_PROGRAM_TEXT("pid 0 0 0 0"));
+}
+
+}  // namespace
+
+uint16_t computeDemandFromTerms(int32_t proportional, int32_t integral, int32_t derivative, int32_t feedForward) {
+    const uint32_t wrappedSum = static_cast<uint32_t>(proportional) + static_cast<uint32_t>(integral) +
+                                static_cast<uint32_t>(derivative) + static_cast<uint32_t>(feedForward);
+    const int32_t signedSum = static_cast<int32_t>(wrappedSum);
+    int32_t clamped = signedSum;
+    if (clamped < 0) {
+        clamped = 0;
+    } else if (clamped > 65535) {
+        clamped = 65535;
+    }
+    return static_cast<uint16_t>(clamped >> 6);
+}
+
+uint16_t mapDemandToAmplitude(uint16_t demand, uint16_t filteredPhase) {
+    const float scaledDemand = static_cast<float>(demand) * 3476.47998046875f;
+    const uint32_t floorOfScaledDemand = convertFloatToUnsigned32(scaledDemand, nullptr);
+    const int32_t first = static_cast<int16_t>(static_cast<uint16_t>(floorOfScaledDemand >> 10));
+    const uint32_t demandWide = demand;
+    const uint32_t second = (demandWide * demandWide * 2303u) >> 20;
+    const uint32_t residual = static_cast<uint32_t>(first) - second - 2396u;
+    const int32_t demandTimesResidual = static_cast<int32_t>(demandWide * residual);
+    const uint32_t intermediate = static_cast<uint32_t>(signedDivideBy1024(demandTimesResidual)) + 1418u;
+    const int32_t phaseTimesIntermediate = static_cast<int32_t>(static_cast<uint32_t>(filteredPhase) * intermediate);
+    return static_cast<uint16_t>(signedDivide(phaseTimesIntermediate, 1608));
+}
+
+void initializeHeaterController(FirmwareState* state, uint32_t currentMilliseconds) {
+    HeaterState& heater = state->heater;
+    heater = HeaterState{};
+    heater.integralAccumulator = kInitialIntegralAccumulator;
+    heater.previousAggregate = kInitialPreviousAggregate;
+    heater.periodTimerBaseline = currentMilliseconds;
+}
+
+void updateControllerPeriodTimer(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    HeaterState& heater = state->heater;
+    if (static_cast<uint32_t>(foregroundSnapshotMilliseconds - heater.periodTimerBaseline) > state->settings.controllerUpdatePeriod) {
+        heater.isPeriodElapsed = true;
+        heater.periodTimerBaseline = foregroundSnapshotMilliseconds;
+    } else {
+        heater.isPeriodElapsed = false;
+    }
+}
+
+void resetIntegralAccumulator(FirmwareState* state) {
+    const SettingsValues& settings = state->settings;
+    const uint16_t setpoint = static_cast<uint16_t>(settings.brewSetpoint);
+    const uint16_t measurement = static_cast<uint16_t>(state->sensor.firstChannelAggregate);
+    const uint16_t difference = static_cast<uint16_t>(setpoint - measurement);
+    const uint16_t span = static_cast<uint16_t>(settings.integralAccumulatorMaximum - settings.integralAccumulatorMinimum);
+    uint32_t candidate = unsignedDivide(static_cast<uint32_t>(difference) * span, setpoint);
+    candidate += settings.integralAccumulatorMinimum;
+    candidate *= 100u;
+    candidate = unsignedDivide(candidate, settings.integralGain);
+    state->heater.integralAccumulator = clampAccumulatorCandidate(settings, static_cast<int32_t>(candidate));
+}
+
+void runHeaterController(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    HeaterState& heater = state->heater;
+    const SettingsValues& settings = state->settings;
+    OutputGroup& boiler = groupForRole(&state->outputChannels, OutputGroupRole::Boiler);
+
+    const int16_t aggregate = state->sensor.firstChannelAggregate;
+    if (aggregate < kMinimumUsableAggregate) {
+        setGroupEnabled(&boiler, false);
+        return;
+    }
+
+    if (state->alternateModeSelection != 0 && heater.bypassSelector == 0) {
+        setGroupAmplitude(&boiler, 0);
+        const bool shouldEnable = aggregate < settings.steamSetpoint;
+        if (shouldEnable != (boiler.isEnabled != 0)) {
+            reportHeaterEnableChange(state, shouldEnable);
+        }
+        setGroupEnabled(&boiler, shouldEnable);
+        return;
+    }
+
+    if (heater.isPeriodElapsed) {
+        const int16_t setpoint = signedSetpointForMode(*state);
+        const int16_t measurement = state->sensor.firstChannelAggregate;
+        const int16_t error = static_cast<int16_t>(static_cast<uint16_t>(setpoint) - static_cast<uint16_t>(measurement));
+        heater.proportionalTerm = static_cast<int32_t>(error) * static_cast<int32_t>(settings.proportionalGain);
+
+        bool isIntegrationFrozen = false;
+        if (heater.shotBeginMarker != 0) {
+            if (heater.shotEndMarker == 0) {
+                isIntegrationFrozen = true;
+            } else if (foregroundSnapshotMilliseconds < heater.shotEndMarker + heater.retainedLockDuration) {
+                isIntegrationFrozen = true;
+            } else {
+                heater.shotEndMarker = 0;
+                heater.shotBeginMarker = 0;
+            }
+        }
+        const uint16_t warmThreshold = static_cast<uint16_t>(static_cast<uint16_t>(setpoint) - (static_cast<uint16_t>(setpoint) >> 4));
+        if (!isIntegrationFrozen && static_cast<uint16_t>(measurement) > warmThreshold) {
+            const int32_t candidate = static_cast<int32_t>(static_cast<uint32_t>(heater.integralAccumulator) + static_cast<uint32_t>(error));
+            heater.integralAccumulator = clampAccumulatorCandidate(settings, candidate);
+        }
+        heater.integralTerm = signedDivide(
+            static_cast<int32_t>(static_cast<uint32_t>(settings.integralGain) * static_cast<uint32_t>(heater.integralAccumulator)), 100);
+
+        const int16_t delta = static_cast<int16_t>(heater.previousAggregate - static_cast<uint16_t>(measurement));
+        const int32_t derivativeBase = static_cast<int32_t>(delta) * static_cast<int32_t>(settings.derivativeGain);
+        const uint32_t factor = (heater.shotBeginMarker != 0 && delta < 0) ? 2u : 1u;
+        heater.derivativeTerm = static_cast<int32_t>(static_cast<uint32_t>(derivativeBase) * factor);
+        heater.previousAggregate = static_cast<uint16_t>(measurement);
+
+        heater.demand = computeDemandFromTerms(heater.proportionalTerm, heater.integralTerm, heater.derivativeTerm, heater.feedForwardTerm);
+        enqueueDiagnosticCallback(&state->diagnosticQueue, reportHeaterTermsFrame);
+
+        if (settings.continuousHeaterControlEnabled != 0) {
+            writeHeaterAmplitudeThroughWrapper(state, heater.demand);
+            setGroupEnabled(&boiler, true);
+        } else {
+            setGroupAmplitude(&boiler, 0);
+        }
+    }
+
+    if (settings.continuousHeaterControlEnabled != 0) {
+        const uint32_t sampleScale = state->sensor.firstRangeFlag == 0 ? 11000u : 22000u;
+        const uint16_t continuousMeasurement = static_cast<uint16_t>((static_cast<uint32_t>(state->sensor.firstRing[0]) * sampleScale) >> 10);
+        const int16_t setpoint = signedSetpointForMode(*state);
+        const int16_t error = static_cast<int16_t>(static_cast<uint16_t>(setpoint) - continuousMeasurement);
+        heater.proportionalTerm = static_cast<int32_t>(error) * static_cast<int32_t>(settings.proportionalGain);
+        heater.demand = computeDemandFromTerms(heater.proportionalTerm, heater.integralTerm, heater.derivativeTerm, heater.feedForwardTerm);
+        writeHeaterAmplitudeThroughWrapper(state, heater.demand);
+    } else {
+        const uint16_t onTime = static_cast<uint16_t>(5u * heater.demand);
+        setGroupEnabled(&boiler, static_cast<uint32_t>(foregroundSnapshotMilliseconds - heater.periodTimerBaseline) < onTime);
+    }
+
+    if (isInputQualified(state->shotInput)) {
+        const uint32_t shotDuration = foregroundSnapshotMilliseconds - state->shotInput.beginTimestamp;
+        const uint16_t pressureDemand = computePressureDemand(settings, shotDuration);
+        setGroupAmplitude(&groupForRole(&state->outputChannels, OutputGroupRole::Pump),
+                          mapDemandToAmplitude(pressureDemand, state->mainsPhase.filteredTicksPerHalfCycle));
+    }
+}
+
+void beginShotFeedForward(FirmwareState* state, uint32_t currentMilliseconds) {
+    HeaterState& heater = state->heater;
+    heater.shotBeginMarker = currentMilliseconds;
+    heater.shotEndMarker = 0;
+    const int32_t product = static_cast<int32_t>(65535u * static_cast<uint32_t>(state->settings.shotFeedForwardPercentage));
+    const float quotient = static_cast<float>(product) / 100.0f;
+    heater.feedForwardTerm = convertFloatToSigned32(quotient);
+}
+
+void endShotFeedForward(FirmwareState* state, uint32_t currentMilliseconds) {
+    HeaterState& heater = state->heater;
+    if (heater.shotBeginMarker != 0) {
+        heater.shotEndMarker = currentMilliseconds;
+        heater.retainedLockDuration = static_cast<uint32_t>(state->settings.shotFeedForwardLockDuration) * 1000u;
+    }
+    heater.feedForwardTerm = 0;
+}
+
+uint16_t computePressureDemand(const SettingsValues& settings, uint32_t durationMilliseconds) {
+    const int16_t delta = static_cast<int16_t>(settings.pressureProfileEnd - settings.pressureProfileStart);
+    const float denominator = static_cast<float>(settings.pressureProfileDuration) * 1000.0f;
+    const float ratio = static_cast<float>(durationMilliseconds) / denominator;
+    const float value = ratio * static_cast<float>(delta) + static_cast<float>(static_cast<int16_t>(settings.pressureProfileStart));
+    const uint16_t scaledValue = static_cast<uint16_t>(convertFloatToUnsigned32(value, nullptr));
+    const float scaled = static_cast<float>(scaledValue) * 1024.0f / 100.0f;
+    if (scaled > 1023.0f) {
+        return 1023;
+    }
+    return static_cast<uint16_t>(convertFloatToUnsigned32(scaled, nullptr));
+}
+
+}  // namespace meCoffee
+
+#endif

+ 39 - 0
lib/meCoffee_controller_logic/src/heater_controller.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+constexpr uint16_t kInitialIntegralAccumulator = 7500;
+constexpr uint16_t kInitialPreviousAggregate = 2000;
+
+// Amplitude (pulse start threshold) for a demand of `demand` at mains phase
+// `filteredPhase`, before the heater wrapper's low-value replacement.
+uint16_t mapDemandToAmplitude(uint16_t demand, uint16_t filteredPhase);
+
+// Sum of the four terms modulo 2^32, read as signed, clamped to 0..65535 and shifted right by six.
+uint16_t computeDemandFromTerms(int32_t proportional, int32_t integral, int32_t derivative, int32_t feedForward);
+
+// Start-up state of the controller (accumulator 7500, previous aggregate 2000).
+void initializeHeaterController(FirmwareState* state, uint32_t currentMilliseconds);
+
+// Refreshes the period latch from the live `controllerUpdatePeriod` setting.
+void updateControllerPeriodTimer(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds);
+
+// Runs the controller once (see the specification's controller ordering).
+void runHeaterController(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds);
+
+// Recomputes the accumulator from the setpoint/measurement pair, as done at
+// power-on and when the first samples are below the setpoint.
+void resetIntegralAccumulator(FirmwareState* state);
+
+// Shot begin/end events: capture or clear the feed-forward term and lock duration.
+void beginShotFeedForward(FirmwareState* state, uint32_t currentMilliseconds);
+void endShotFeedForward(FirmwareState* state, uint32_t currentMilliseconds);
+
+// Pressure-profile pump demand for a shot that has lasted `durationMilliseconds`.
+uint16_t computePressureDemand(const SettingsValues& settings, uint32_t durationMilliseconds);
+
+}  // namespace meCoffee

+ 27 - 0
lib/meCoffee_controller_logic/src/integer_arithmetic.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+// Division helpers with the original image's edge-case results.
+inline int32_t signedDivide(int32_t numerator, int32_t denominator) {
+    if (denominator == 0) {
+        return numerator < 0 ? 1 : -1;
+    }
+    if (numerator == INT32_MIN && denominator == -1) {
+        return INT32_MIN;
+    }
+    return numerator / denominator;
+}
+
+// Division by 1024 truncating toward zero, without a library division.
+inline int32_t signedDivideBy1024(int32_t numerator) {
+    return (numerator + ((numerator >> 31) & 1023)) >> 10;
+}
+
+inline uint32_t unsignedDivide(uint32_t numerator, uint32_t denominator) {
+    return denominator == 0 ? 0xffffffffUL : numerator / denominator;
+}
+
+}  // namespace meCoffee

+ 64 - 0
lib/meCoffee_controller_logic/src/mains_events.cpp

@@ -0,0 +1,64 @@
+#include "mains_events.h"
+
+#include "hardware_seam.h"
+
+namespace meCoffee {
+
+namespace {
+
+constexpr uint16_t kMinimumQualifiedPhaseTicks = 100;
+constexpr uint32_t kClockResetThresholdSeconds = 86401;
+
+void advanceEventClock(EventClock* clock) {
+    if (clock->pendingSeconds != 0 && clock->isValid != 0) {
+        clock->currentSeconds = clock->pendingSeconds;
+        clock->pendingSeconds = 0;
+    }
+    clock->fractionCount = static_cast<uint8_t>(clock->fractionCount + 1);
+    if (clock->fractionCount == clock->fractionPeriod) {
+        clock->fractionCount = 0;
+        ++clock->currentSeconds;
+        if (clock->currentSeconds >= kClockResetThresholdSeconds) {
+            clock->currentSeconds = 0;
+        }
+    }
+}
+
+}  // namespace
+
+void processMainsEdge(FirmwareState* state) {
+    MainsPhase& phase = state->mainsPhase;
+    phase.lastEventTimestamp = state->foregroundSnapshotMilliseconds;
+
+    const uint16_t currentPhaseTicks = phase.ticksSinceQualifiedEvent;
+    if (currentPhaseTicks < kMinimumQualifiedPhaseTicks) {
+        return;
+    }
+
+    const uint16_t previousFilteredPhase = phase.filteredTicksPerHalfCycle;
+    const uint16_t seedPhase = previousFilteredPhase == 0 ? currentPhaseTicks : previousFilteredPhase;
+    phase.filteredTicksPerHalfCycle = static_cast<uint16_t>(static_cast<uint16_t>(seedPhase + currentPhaseTicks) >> 1);
+    phase.ticksSinceQualifiedEvent = 0;
+
+    reloadPendingToggleCounts(&state->outputChannels);
+    hardware::clearPulseOutputPins();
+
+    advanceEventClock(&state->eventClock);
+    phase.qualifiedEventCount = static_cast<uint8_t>(phase.qualifiedEventCount + 1);
+}
+
+uint32_t readLastEventTimestampAtomically(const FirmwareState& state) {
+    hardware::disableInterrupts();
+    const uint32_t timestamp = state.mainsPhase.lastEventTimestamp;
+    hardware::enableInterrupts();
+    return timestamp;
+}
+
+uint32_t readEventClockSecondsAtomically(const FirmwareState& state) {
+    hardware::disableInterrupts();
+    const uint32_t seconds = state.eventClock.currentSeconds;
+    hardware::enableInterrupts();
+    return seconds;
+}
+
+}  // namespace meCoffee

+ 18 - 0
lib/meCoffee_controller_logic/src/mains_events.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Handler for one edge of the mains-synchronised input. Always records the
+// foreground time stamp; only a phase of 100 ticks or more counts as a
+// qualified event and updates the filtered phase, reloads the pulse counts,
+// clears the pulse pins and advances the event-driven clock.
+void processMainsEdge(FirmwareState* state);
+
+// Reads of 32-bit values that the mains-edge handler updates, taken with
+// interrupts masked so a handler cannot change them mid-read.
+uint32_t readLastEventTimestampAtomically(const FirmwareState& state);
+uint32_t readEventClockSecondsAtomically(const FirmwareState& state);
+
+}  // namespace meCoffee

+ 151 - 0
lib/meCoffee_controller_logic/src/module_provisioning.cpp

@@ -0,0 +1,151 @@
+#if !defined(MECOFFEE_RUST_RADIO)
+#include "module_provisioning.h"
+
+#include "hardware_seam.h"
+#include "program_memory.h"
+#include "serial_output.h"
+
+namespace meCoffee {
+
+namespace {
+
+constexpr uint8_t kModuleResetLogicalPin = 15;
+constexpr uint8_t kModuleConfigurationModeLogicalPin = 19;
+constexpr uint32_t kNormalBaudRate = 57600;
+constexpr uint32_t kConfigurationModeBaudRate = 38400;
+constexpr uint32_t kFallbackBaudRate = 9600;
+constexpr uint32_t kReplyCaptureDuration = 1500;
+constexpr uint16_t kModuleSetupCompleteMarker = 42;
+constexpr uint8_t kStructuredReplyCapacity = 15;
+constexpr uint8_t kFallbackReplyCapacity = 14;
+
+void pulseModuleReset() {
+    hardware::writeLogicalPin(kModuleResetLogicalPin, false);
+    hardware::blockForMilliseconds(50);
+    hardware::writeLogicalPin(kModuleResetLogicalPin, true);
+    hardware::blockForMilliseconds(500);
+}
+
+void selectModuleMode(bool isConfigurationMode) {
+    hardware::waitForSerialTransmitCompletion();
+    hardware::writeLogicalPin(kModuleConfigurationModeLogicalPin, isConfigurationMode);
+    pulseModuleReset();
+    hardware::beginSerialLink(isConfigurationMode ? kConfigurationModeBaudRate : kNormalBaudRate);
+    hardware::blockForMilliseconds(100);
+}
+
+void sendAtText(const char* programMemoryText) {
+    writeProgramMemoryText(programMemoryText);
+    hardware::waitForSerialTransmitCompletion();
+    hardware::blockForMilliseconds(100);
+}
+
+void sendAtCommand(const char* programMemoryParameter) {
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("AT+"));
+    writeProgramMemoryText(programMemoryParameter);
+    writeCarriageReturnLineFeed();
+    hardware::waitForSerialTransmitCompletion();
+    hardware::blockForMilliseconds(100);
+}
+
+void discardAvailableReceivedBytes() {
+    while (hardware::readSerialByte() >= 0) {
+    }
+}
+
+// Captures nonzero bytes for the fixed duration; a received NUL is stored but not
+// counted, so the next byte overwrites it.
+uint8_t captureReply(char* replyBuffer, uint8_t capacity) {
+    const uint32_t captureStart = hardware::readMillisecondCounter();
+    uint8_t capturedByteCount = 0;
+    while (hardware::readMillisecondCounter() - captureStart < kReplyCaptureDuration) {
+        if (capturedByteCount >= capacity) {
+            continue;
+        }
+        const int receivedByte = hardware::readSerialByte();
+        if (receivedByte < 0) {
+            continue;
+        }
+        replyBuffer[capturedByteCount] = static_cast<char>(receivedByte);
+        if (receivedByte != 0) {
+            ++capturedByteCount;
+        }
+    }
+    replyBuffer[capturedByteCount] = '\0';
+    return capturedByteCount;
+}
+
+bool isAcknowledgement(const char* replyBuffer, uint8_t capturedByteCount) {
+    return capturedByteCount >= 2 && replyBuffer[0] == 'O' && replyBuffer[1] == 'K';
+}
+
+bool configureModuleWithStructuredCommands(FirmwareState* state) {
+    selectModuleMode(true);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("AT+UART="));
+    writeUnsignedDecimalText(kNormalBaudRate);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(",0,0"));
+    writeCarriageReturnLineFeed();
+    hardware::waitForSerialTransmitCompletion();
+    hardware::blockForMilliseconds(100);
+    sendAtCommand(MECOFFEE_PROGRAM_TEXT("ROLE=0"));
+    sendAtCommand(MECOFFEE_PROGRAM_TEXT("CMODE=1"));
+    sendAtCommand(MECOFFEE_PROGRAM_TEXT("POLAR=1,0"));
+    sendAtCommand(MECOFFEE_PROGRAM_TEXT("NAME=meCoffee"));
+    sendAtCommand(MECOFFEE_PROGRAM_TEXT("PSWD=4321"));
+    discardAvailableReceivedBytes();
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("AT\r\n"));
+    hardware::waitForSerialTransmitCompletion();
+    const uint8_t capturedByteCount = captureReply(state->module.capturedReply, kStructuredReplyCapacity);
+    selectModuleMode(false);
+    return isAcknowledgement(state->module.capturedReply, capturedByteCount);
+}
+
+bool configureModuleWithFallbackCommands() {
+    char fallbackReply[kFallbackReplyCapacity + 2];
+    for (uint8_t attempt = 0; attempt < 2; ++attempt) {
+        hardware::beginSerialLink(attempt == 0 ? kFallbackBaudRate : kNormalBaudRate);
+        hardware::blockForMilliseconds(1000);
+        sendAtText(MECOFFEE_PROGRAM_TEXT("AT+NAMEmeCoffee"));
+        sendAtText(MECOFFEE_PROGRAM_TEXT("AT"));
+        discardAvailableReceivedBytes();
+        sendAtText(MECOFFEE_PROGRAM_TEXT("AT+BAUD3"));
+        const uint8_t capturedByteCount = captureReply(fallbackReply, kFallbackReplyCapacity);
+        if (isAcknowledgement(fallbackReply, capturedByteCount)) {
+            hardware::beginSerialLink(kNormalBaudRate);
+            return true;
+        }
+    }
+    hardware::beginSerialLink(kNormalBaudRate);
+    return false;
+}
+
+}  // namespace
+
+void provisionRadioModuleIfNeeded(FirmwareState* state) {
+    if (state->settings.bluetoothModuleSetupState == kModuleSetupCompleteMarker) {
+        return;
+    }
+    state->module.isPresent = true;
+    state->module.capturedReply[0] = '\0';
+    hardware::configureLogicalPin(kModuleConfigurationModeLogicalPin, hardware::PinConfiguration::Output);
+    hardware::writeLogicalPin(kModuleConfigurationModeLogicalPin, false);
+    hardware::configureLogicalPin(kModuleResetLogicalPin, hardware::PinConfiguration::Output);
+    hardware::writeLogicalPin(kModuleResetLogicalPin, true);
+    hardware::blockForMilliseconds(1000);
+
+    if (configureModuleWithStructuredCommands(state) || configureModuleWithFallbackCommands()) {
+        state->settings.bluetoothModuleSetupState = kModuleSetupCompleteMarker;
+        persistSetting(&state->settings, state->settingRecordHandles,
+                       static_cast<uint8_t>(findSettingIndexByProtocolKey("btstp")));
+    }
+}
+
+void captureRadioModuleReplyInAtMode(FirmwareState* state) {
+    selectModuleMode(true);
+    captureReply(state->module.capturedReply, kModuleCaptureCapacity);
+    selectModuleMode(false);
+}
+
+}  // namespace meCoffee
+
+#endif

+ 15 - 0
lib/meCoffee_controller_logic/src/module_provisioning.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Runs the radio-module configuration exchange unless the module-setup marker
+// (btstp == 42) is already stored. Blocks for several seconds, as the original does.
+void provisionRadioModuleIfNeeded(FirmwareState* state);
+
+// Host `set btname`: enters AT mode, captures up to 15 reply bytes for 1.5 s into
+// the shared reply buffer, and leaves AT mode. Sends no name.
+void captureRadioModuleReplyInAtMode(FirmwareState* state);
+
+}  // namespace meCoffee

+ 325 - 0
lib/meCoffee_controller_logic/src/numeric_conversion.cpp

@@ -0,0 +1,325 @@
+#include "numeric_conversion.h"
+
+#include <string.h>
+
+#include "hardware_seam.h"
+#include "program_memory.h"
+
+namespace meCoffee {
+
+#if defined(MECOFFEE_RUST_NUMERIC)
+extern "C" {
+float mecoffee_rust_parse_decimal(const char*, DecimalParseDiagnostics*);
+uint32_t mecoffee_rust_parse_integer(const char*);
+uint32_t mecoffee_rust_float_unsigned(float, bool*);
+int32_t mecoffee_rust_float_signed(float);
+uint8_t mecoffee_rust_format_unsigned(uint32_t, char*);
+}
+static_assert(sizeof(DecimalParseDiagnostics) == 11, "Rust decimal diagnostics layout");
+static_assert(sizeof(bool) == 1, "Rust status byte layout");
+float parseDecimalFloatingPoint(const char* text, DecimalParseDiagnostics* diagnostics) {
+    return mecoffee_rust_parse_decimal(text, diagnostics);
+}
+uint32_t convertFloatToUnsigned32(float value, bool* flagged) {
+    return mecoffee_rust_float_unsigned(value, flagged);
+}
+int32_t convertFloatToSigned32(float value) { return mecoffee_rust_float_signed(value); }
+uint16_t parseIntegerPrefixModulo65536(const char* text) {
+    return static_cast<uint16_t>(mecoffee_rust_parse_integer(text));
+}
+uint32_t parseIntegerPrefixModulo4294967296(const char* text) {
+    return mecoffee_rust_parse_integer(text);
+}
+uint8_t formatUnsignedDecimal(uint32_t value, char* output) {
+    return mecoffee_rust_format_unsigned(value, output);
+}
+uint8_t formatSignedDecimal(int32_t value, char* output) {
+    if (value < 0) {
+        output[0] = '-';
+        return 1 + formatUnsignedDecimal(0 - static_cast<uint32_t>(value), output + 1);
+    }
+    return formatUnsignedDecimal(static_cast<uint32_t>(value), output);
+}
+#else
+
+namespace {
+
+// binary32 bit patterns for 10^-w and 10^+w with w = 1, 2, 4, 8, 16, 32.
+const uint32_t kNegativeDecimalPowerBitPatterns[6] MECOFFEE_PROGRAM_MEMORY = {
+    0x3dcccccdUL, 0x3c23d70aUL, 0x38d1b717UL, 0x322bcc77UL, 0x24e69595UL, 0x0a4fb11fUL};
+const uint32_t kPositiveDecimalPowerBitPatterns[6] MECOFFEE_PROGRAM_MEMORY = {
+    0x41200000UL, 0x42c80000UL, 0x461c4000UL, 0x4cbebc20UL, 0x5a0e1bcaUL, 0x749dc5aeUL};
+const uint8_t kDecimalPowerWeights[6] = {1, 2, 4, 8, 16, 32};
+
+constexpr uint32_t kSignificandOverflowThreshold = 429496728UL;
+constexpr uint16_t kExponentDigitFreezeThreshold = 3200;
+constexpr uint32_t kCanonicalNotANumberBitPattern = 0x7fc00000UL;
+constexpr uint32_t kPositiveInfinityBitPattern = 0x7f800000UL;
+
+float reinterpretBitPatternAsFloat(uint32_t bitPattern) {
+    float reinterpretedValue;
+    memcpy(&reinterpretedValue, &bitPattern, sizeof reinterpretedValue);
+    return reinterpretedValue;
+}
+
+uint32_t reinterpretFloatAsBitPattern(float numericValue) {
+    uint32_t bitPattern;
+    memcpy(&bitPattern, &numericValue, sizeof bitPattern);
+    return bitPattern;
+}
+
+uint32_t readProgramMemoryUnsigned32(const uint32_t* programMemoryAddress) {
+    const uint8_t* firstByte = reinterpret_cast<const uint8_t*>(programMemoryAddress);
+    uint32_t assembledValue = 0;
+    for (uint8_t byteIndex = 0; byteIndex < 4; ++byteIndex) {
+        assembledValue |= static_cast<uint32_t>(hardware::readProgramMemoryByte(firstByte + byteIndex))
+                          << (8 * byteIndex);
+    }
+    return assembledValue;
+}
+
+bool isWhitespaceCharacter(char character) {
+    return character == ' ' || (character >= 0x09 && character <= 0x0d);
+}
+
+bool isDecimalDigit(char character) { return character >= '0' && character <= '9'; }
+
+char toLowerCaseLetter(char character) {
+    return (character >= 'A' && character <= 'Z') ? static_cast<char>(character + ('a' - 'A')) : character;
+}
+
+bool startsWithCaseInsensitive(const char* text, const char* lowerCasePrefix) {
+    while (*lowerCasePrefix != '\0') {
+        if (toLowerCaseLetter(*text) != *lowerCasePrefix) {
+            return false;
+        }
+        ++text;
+        ++lowerCasePrefix;
+    }
+    return true;
+}
+
+}  // namespace
+
+float parseDecimalFloatingPoint(const char* text, DecimalParseDiagnostics* diagnostics) {
+    DecimalParseDiagnostics localDiagnostics = {};
+    DecimalParseDiagnostics& recorded = diagnostics != nullptr ? *diagnostics : localDiagnostics;
+    recorded = DecimalParseDiagnostics{};
+
+    const char* cursor = text;
+    while (isWhitespaceCharacter(*cursor)) {
+        ++cursor;
+    }
+    bool isNegative = false;
+    if (*cursor == '-') {
+        isNegative = true;
+        ++cursor;
+    } else if (*cursor == '+') {
+        ++cursor;
+    }
+
+    if (startsWithCaseInsensitive(cursor, "nan")) {
+        recorded.consumedCharacterCount = static_cast<uint16_t>(cursor + 3 - text);
+        return reinterpretBitPatternAsFloat(kCanonicalNotANumberBitPattern);
+    }
+    if (startsWithCaseInsensitive(cursor, "inf")) {
+        cursor += 3;
+        if (startsWithCaseInsensitive(cursor, "inity")) {
+            cursor += 5;
+        }
+        recorded.consumedCharacterCount = static_cast<uint16_t>(cursor - text);
+        return reinterpretBitPatternAsFloat(kPositiveInfinityBitPattern | (isNegative ? 0x80000000UL : 0));
+    }
+
+    uint32_t significand = 0;
+    uint16_t decimalExponentOffset = 0;
+    uint16_t discardedDigitCount = 0;
+    bool hasSignificandDigit = false;
+    bool hasSeenDecimalPoint = false;
+    bool isDiscardingDigits = false;
+
+    for (;; ++cursor) {
+        const char character = *cursor;
+        if (isDecimalDigit(character)) {
+            hasSignificandDigit = true;
+            if (isDiscardingDigits) {
+                ++discardedDigitCount;
+                if (!hasSeenDecimalPoint) {
+                    ++decimalExponentOffset;
+                }
+            } else {
+                if (hasSeenDecimalPoint) {
+                    --decimalExponentOffset;
+                }
+                significand = significand * 10 + static_cast<uint8_t>(character - '0');
+                if (significand >= kSignificandOverflowThreshold) {
+                    isDiscardingDigits = true;
+                }
+            }
+        } else if (character == '.' && !hasSeenDecimalPoint) {
+            hasSeenDecimalPoint = true;
+        } else {
+            break;
+        }
+    }
+
+    if (!hasSignificandDigit) {
+        return 0.0f;
+    }
+
+    if (*cursor == 'e' || *cursor == 'E') {
+        const char* exponentCursor = cursor + 1;
+        bool isExponentNegative = false;
+        if (*exponentCursor == '-') {
+            isExponentNegative = true;
+            ++exponentCursor;
+        } else if (*exponentCursor == '+') {
+            ++exponentCursor;
+        }
+        if (isDecimalDigit(*exponentCursor)) {
+            uint16_t explicitExponent = 0;
+            while (isDecimalDigit(*exponentCursor)) {
+                if (explicitExponent < kExponentDigitFreezeThreshold) {
+                    explicitExponent = static_cast<uint16_t>(explicitExponent * 10 + (*exponentCursor - '0'));
+                }
+                ++exponentCursor;
+            }
+            decimalExponentOffset = static_cast<uint16_t>(
+                decimalExponentOffset + (isExponentNegative ? static_cast<uint16_t>(0 - explicitExponent) : explicitExponent));
+            cursor = exponentCursor;
+        }
+    }
+
+    recorded.retainedSignificand = significand;
+    recorded.decimalExponentOffset = static_cast<int16_t>(decimalExponentOffset);
+    recorded.discardedSignificandDigitCount = discardedDigitCount;
+    recorded.consumedCharacterCount = static_cast<uint16_t>(cursor - text);
+
+    float scaledValue = static_cast<float>(significand);
+    if (isNegative) {
+        scaledValue = -scaledValue;
+    }
+    if (significand == 0) {
+        return scaledValue;
+    }
+
+    const int16_t signedExponentOffset = static_cast<int16_t>(decimalExponentOffset);
+    const bool isScalingDownward = signedExponentOffset < 0;
+    uint16_t remainingExponentMagnitude = isScalingDownward
+        ? static_cast<uint16_t>(0 - static_cast<uint16_t>(signedExponentOffset))
+        : static_cast<uint16_t>(signedExponentOffset);
+    const uint32_t* powerTable = isScalingDownward ? kNegativeDecimalPowerBitPatterns : kPositiveDecimalPowerBitPatterns;
+
+    for (int8_t weightIndex = 5; weightIndex >= 0; --weightIndex) {
+        const float decimalPower = reinterpretBitPatternAsFloat(readProgramMemoryUnsigned32(&powerTable[weightIndex]));
+        while (remainingExponentMagnitude >= kDecimalPowerWeights[weightIndex]) {
+            scaledValue *= decimalPower;
+            remainingExponentMagnitude = static_cast<uint16_t>(remainingExponentMagnitude - kDecimalPowerWeights[weightIndex]);
+        }
+    }
+
+    const uint32_t scaledBitPattern = reinterpretFloatAsBitPattern(scaledValue);
+    const bool isInfinite = (scaledBitPattern & 0x7fffffffUL) == kPositiveInfinityBitPattern;
+    const bool isZero = (scaledBitPattern & 0x7fffffffUL) == 0;
+    recorded.isOutOfRangeIndicatorRaised = isInfinite || isZero;
+    return scaledValue;
+}
+
+uint32_t convertFloatToUnsigned32(float numericValue, bool* isConversionStatusFlagged) {
+    bool isFlagged = false;
+    uint32_t convertedValue = 0;
+
+    const uint32_t bitPattern = reinterpretFloatAsBitPattern(numericValue);
+    const bool isNegative = (bitPattern & 0x80000000UL) != 0;
+    const uint8_t biasedExponent = static_cast<uint8_t>((bitPattern >> 23) & 0xff);
+
+    if (biasedExponent == 0xff) {
+        isFlagged = true;
+    } else if (biasedExponent >= 127) {
+        const uint8_t unbiasedExponent = static_cast<uint8_t>(biasedExponent - 127);
+        if (unbiasedExponent >= 32) {
+            isFlagged = true;
+        } else {
+            const uint32_t significandWithHiddenBit = (bitPattern & 0x007fffffUL) | 0x00800000UL;
+            const uint32_t magnitude = unbiasedExponent >= 23
+                ? significandWithHiddenBit << (unbiasedExponent - 23)
+                : significandWithHiddenBit >> (23 - unbiasedExponent);
+            if (magnitude >= 0x80000000UL) {
+                isFlagged = true;
+            }
+            convertedValue = isNegative ? static_cast<uint32_t>(0 - magnitude) : magnitude;
+        }
+    }
+
+    if (isConversionStatusFlagged != nullptr) {
+        *isConversionStatusFlagged = isFlagged;
+    }
+    return convertedValue;
+}
+
+int32_t convertFloatToSigned32(float numericValue) {
+    bool isFlagged = false;
+    const uint32_t convertedValue = convertFloatToUnsigned32(numericValue, &isFlagged);
+    if (isFlagged) {
+        return INT32_MIN;
+    }
+    return static_cast<int32_t>(convertedValue);
+}
+
+namespace {
+
+uint32_t parseIntegerPrefix(const char* text, uint8_t bitWidth) {
+    const char* cursor = text;
+    while (isWhitespaceCharacter(*cursor)) {
+        ++cursor;
+    }
+    bool isNegative = false;
+    if (*cursor == '-') {
+        isNegative = true;
+        ++cursor;
+    } else if (*cursor == '+') {
+        ++cursor;
+    }
+    uint32_t accumulated = 0;
+    while (isDecimalDigit(*cursor)) {
+        accumulated = accumulated * 10 + static_cast<uint8_t>(*cursor - '0');
+        ++cursor;
+    }
+    if (isNegative) {
+        accumulated = 0 - accumulated;
+    }
+    return bitWidth == 16 ? (accumulated & 0xffffUL) : accumulated;
+}
+
+}  // namespace
+
+uint16_t parseIntegerPrefixModulo65536(const char* text) {
+    return static_cast<uint16_t>(parseIntegerPrefix(text, 16));
+}
+
+uint32_t parseIntegerPrefixModulo4294967296(const char* text) { return parseIntegerPrefix(text, 32); }
+
+uint8_t formatUnsignedDecimal(uint32_t numericValue, char* outputBuffer) {
+    char reversedDigits[10];
+    uint8_t digitCount = 0;
+    do {
+        reversedDigits[digitCount++] = static_cast<char>('0' + numericValue % 10);
+        numericValue /= 10;
+    } while (numericValue != 0);
+    for (uint8_t position = 0; position < digitCount; ++position) {
+        outputBuffer[position] = reversedDigits[digitCount - 1 - position];
+    }
+    return digitCount;
+}
+
+uint8_t formatSignedDecimal(int32_t numericValue, char* outputBuffer) {
+    if (numericValue < 0) {
+        outputBuffer[0] = '-';
+        return static_cast<uint8_t>(1 + formatUnsignedDecimal(0 - static_cast<uint32_t>(numericValue), outputBuffer + 1));
+    }
+    return formatUnsignedDecimal(static_cast<uint32_t>(numericValue), outputBuffer);
+}
+
+
+#endif
+}  // namespace meCoffee

+ 41 - 0
lib/meCoffee_controller_logic/src/numeric_conversion.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+// Lexical state of one decimal parse, captured before floating-point scaling.
+struct DecimalParseDiagnostics {
+    uint32_t retainedSignificand;
+    int16_t decimalExponentOffset;
+    uint16_t discardedSignificandDigitCount;
+    uint16_t consumedCharacterCount;
+    // True when scaling produced zero or infinity and the original library
+    // would have recorded its out-of-range indicator.
+    bool isOutOfRangeIndicatorRaised;
+};
+
+// Parses a decimal floating-point prefix of `text` with the original image's
+// staged-multiplication rules. `diagnostics` may be null.
+float parseDecimalFloatingPoint(const char* text, DecimalParseDiagnostics* diagnostics);
+
+// Truncates toward zero and applies the sign modulo 2^32. Not-a-number,
+// infinity and magnitudes of 2^32 or more return 0 and set
+// `isConversionStatusFlagged`; magnitudes of 2^31 or more set it too but keep
+// their modular value.
+uint32_t convertFloatToUnsigned32(float numericValue, bool* isConversionStatusFlagged);
+
+// Signed wrapper: any flagged status yields INT32_MIN.
+int32_t convertFloatToSigned32(float numericValue);
+
+// Integer-prefix readers: optional whitespace, one sign, decimal digits with
+// modular accumulation; stops at the first non-digit.
+uint16_t parseIntegerPrefixModulo65536(const char* text);
+uint32_t parseIntegerPrefixModulo4294967296(const char* text);
+
+// Writes the signed decimal text of `numericValue` (no terminator) into
+// `outputBuffer`, which must hold at least 11 characters. Returns the length.
+uint8_t formatSignedDecimal(int32_t numericValue, char* outputBuffer);
+uint8_t formatUnsignedDecimal(uint32_t numericValue, char* outputBuffer);
+
+}  // namespace meCoffee

+ 63 - 0
lib/meCoffee_controller_logic/src/oscillator_adjustment.cpp

@@ -0,0 +1,63 @@
+#include "oscillator_adjustment.h"
+
+#if defined(MECOFFEE_RUST_OSCILLATOR)
+extern "C" uint32_t mecoffee_rust_adjust_oscillator(uint8_t events, int16_t offset,
+                                                 uint8_t baseline, uint8_t period);
+#endif
+
+namespace meCoffee {
+
+OscillatorAdjustmentResult adjustOscillator(uint8_t qualifiedEventCount, int16_t oldOffset, uint8_t baselineByte,
+                                            uint8_t oldFractionPeriod) {
+#if defined(MECOFFEE_RUST_OSCILLATOR)
+    const uint32_t packed = mecoffee_rust_adjust_oscillator(qualifiedEventCount, oldOffset, baselineByte, oldFractionPeriod);
+    OscillatorAdjustmentResult result;
+    result.newOffset = static_cast<int16_t>(static_cast<uint16_t>(packed));
+    result.newFractionPeriod = static_cast<uint8_t>(packed >> 16);
+    result.calibrationValue = static_cast<uint8_t>(packed >> 24);
+    result.shouldWriteCalibration = result.calibrationValue != 0;
+    return result;
+#else
+    OscillatorAdjustmentResult result;
+    result.newOffset = oldOffset;
+    result.newFractionPeriod = oldFractionPeriod;
+    result.shouldWriteCalibration = false;
+    result.calibrationValue = 0;
+
+    uint8_t selectedPeriod;
+    if (qualifiedEventCount < 105) {
+        selectedPeriod = 100;
+    } else if (qualifiedEventCount >= 116) {
+        selectedPeriod = 120;
+    } else {
+        return result;
+    }
+    result.newFractionPeriod = selectedPeriod;
+
+    uint16_t wrappedOffset = static_cast<uint16_t>(oldOffset);
+    if (qualifiedEventCount < selectedPeriod) {
+        wrappedOffset = static_cast<uint16_t>(wrappedOffset - 1);
+    } else if (qualifiedEventCount > selectedPeriod) {
+        wrappedOffset = static_cast<uint16_t>(wrappedOffset + 1);
+    }
+    int16_t clampedOffset = static_cast<int16_t>(wrappedOffset);
+    if (clampedOffset < -8) {
+        clampedOffset = -8;
+    } else if (clampedOffset > 8) {
+        clampedOffset = 8;
+    }
+    result.newOffset = clampedOffset;
+
+    if (clampedOffset != oldOffset) {
+        uint8_t calibrationValue = static_cast<uint8_t>(baselineByte + static_cast<uint8_t>(clampedOffset));
+        if ((calibrationValue & 0x80) == 0) {
+            calibrationValue = 128;
+        }
+        result.shouldWriteCalibration = true;
+        result.calibrationValue = calibrationValue;
+    }
+    return result;
+#endif
+}
+
+}  // namespace meCoffee

+ 19 - 0
lib/meCoffee_controller_logic/src/oscillator_adjustment.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+struct OscillatorAdjustmentResult {
+    int16_t newOffset;
+    uint8_t newFractionPeriod;
+    bool shouldWriteCalibration;
+    uint8_t calibrationValue;
+};
+
+// Adjustment stage reached on every eighth aggregate generation. `qualifiedEventCount`
+// is the number of qualified mains events in the last generation.
+OscillatorAdjustmentResult adjustOscillator(uint8_t qualifiedEventCount, int16_t oldOffset, uint8_t baselineByte,
+                                            uint8_t oldFractionPeriod);
+
+}  // namespace meCoffee

+ 127 - 0
lib/meCoffee_controller_logic/src/output_channels.cpp

@@ -0,0 +1,127 @@
+#include "output_channels.h"
+
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+#include <avr/io.h>
+#include <avr/interrupt.h>
+#endif
+
+namespace meCoffee {
+
+namespace {
+
+constexpr uint8_t kBoilerToggleCount = 20;
+constexpr uint8_t kPumpToggleCount = 200;
+constexpr uint8_t kValveToggleCount = 200;
+
+void applyGroupStateToLeaf(const OutputGroup& group, OutputLeaf* leaf) {
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+    // Routing tables are foreground-only. Mask only the ISR-visible leaf
+    // publication, not the search/detach/attach traversal around it.
+    const uint8_t saved = SREG;
+    cli();
+#endif
+    leaf->configuredToggleCount = group.configuredToggleCount;
+    leaf->startThreshold = group.amplitude;
+    leaf->isEnabled = group.isEnabled;
+#if defined(MECOFFEE_RUST_COMMAND_HANDLERS)
+    SREG = saved;
+#endif
+}
+
+void attachLeafToGroup(OutputGroup* group, OutputLeaf* leaf) {
+    for (uint8_t slot = 0; slot < kLeavesPerGroup; ++slot) {
+        if (group->attachedLeaves[slot] == nullptr) {
+            group->attachedLeaves[slot] = leaf;
+            applyGroupStateToLeaf(*group, leaf);
+            return;
+        }
+    }
+}
+
+}  // namespace
+
+void initializeOutputChannels(OutputChannels* channels) {
+    for (OutputLeaf& leaf : channels->leaves) {
+        leaf.configuredToggleCount = 0;
+        leaf.startThreshold = 0;
+        leaf.isEnabled = 0;
+        leaf.pendingToggleCount = 0;
+    }
+    for (OutputGroup& group : channels->groups) {
+        group.isEnabled = 0;
+        group.amplitude = 0;
+        group.configuredToggleCount = 0;
+        for (OutputLeaf*& leafSlot : group.attachedLeaves) {
+            leafSlot = nullptr;
+        }
+    }
+    groupForRole(channels, OutputGroupRole::Boiler).configuredToggleCount = kBoilerToggleCount;
+    groupForRole(channels, OutputGroupRole::Pump).configuredToggleCount = kPumpToggleCount;
+    groupForRole(channels, OutputGroupRole::Valve).configuredToggleCount = kValveToggleCount;
+}
+
+OutputGroup& groupForRole(OutputChannels* channels, OutputGroupRole role) {
+    return channels->groups[static_cast<uint8_t>(role)];
+}
+
+void setGroupEnabled(OutputGroup* group, bool shouldBeEnabled) {
+    group->isEnabled = shouldBeEnabled ? 1 : 0;
+    for (OutputLeaf* leaf : group->attachedLeaves) {
+        if (leaf != nullptr) {
+            leaf->isEnabled = group->isEnabled;
+        }
+    }
+}
+
+void setGroupAmplitude(OutputGroup* group, uint16_t amplitude) {
+    group->amplitude = amplitude;
+    for (OutputLeaf* leaf : group->attachedLeaves) {
+        if (leaf != nullptr) {
+            leaf->startThreshold = amplitude;
+        }
+    }
+}
+
+void setGroupToggleCount(OutputGroup* group, uint8_t toggleCount) {
+    group->configuredToggleCount = toggleCount;
+    for (OutputLeaf* leaf : group->attachedLeaves) {
+        if (leaf != nullptr) {
+            leaf->configuredToggleCount = toggleCount;
+        }
+    }
+}
+
+void routeLeafToRoleCharacter(OutputChannels* channels, uint8_t leafIndex, uint8_t roleCharacter) {
+    OutputLeaf* leaf = &channels->leaves[leafIndex];
+    for (OutputGroup& group : channels->groups) {
+        for (OutputLeaf*& leafSlot : group.attachedLeaves) {
+            if (leafSlot == leaf) {
+                leafSlot = nullptr;
+            }
+        }
+    }
+    OutputGroupRole role;
+    switch (roleCharacter) {
+        case 'b': role = OutputGroupRole::Boiler; break;
+        case 'p': role = OutputGroupRole::Pump; break;
+        case 'v': role = OutputGroupRole::Valve; break;
+        case 'i': role = OutputGroupRole::Indicator; break;
+        case 'g': role = OutputGroupRole::Grinder; break;
+        default: return;
+    }
+    attachLeafToGroup(&groupForRole(channels, role), leaf);
+}
+
+void disableAllGroups(OutputChannels* channels) {
+    for (OutputGroup& group : channels->groups) {
+        setGroupEnabled(&group, false);
+    }
+}
+
+void reloadPendingToggleCounts(OutputChannels* channels) {
+    for (OutputLeaf& leaf : channels->leaves) {
+        leaf.pendingToggleCount = leaf.isEnabled ? leaf.configuredToggleCount : 0;
+    }
+}
+
+}  // namespace meCoffee

+ 65 - 0
lib/meCoffee_controller_logic/src/output_channels.h

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+// One pulse output. The fast timer interrupt reads and writes these fields, so
+// every member is volatile.
+struct OutputLeaf {
+    volatile uint8_t configuredToggleCount;
+    volatile uint16_t startThreshold;
+    volatile uint8_t isEnabled;
+    volatile uint8_t pendingToggleCount;
+};
+
+constexpr uint8_t kOutputLeafCount = 3;
+constexpr uint8_t kOutputGroupCount = 5;
+constexpr uint8_t kLeavesPerGroup = 3;
+
+// Logical output groups in the order used by the original routing table.
+enum class OutputGroupRole : uint8_t {
+    Boiler = 0,
+    Pump = 1,
+    Valve = 2,
+    Indicator = 3,
+    Grinder = 4,
+};
+
+struct OutputGroup {
+    uint8_t isEnabled;
+    uint16_t amplitude;
+    uint8_t configuredToggleCount;
+    OutputLeaf* attachedLeaves[kLeavesPerGroup];
+};
+
+struct OutputChannels {
+    OutputLeaf leaves[kOutputLeafCount];
+    OutputGroup groups[kOutputGroupCount];
+};
+
+// Leaf index for output zero, one and two (pump, boiler, valve by default).
+// Logical pins 3, 10 and 9 respectively.
+constexpr uint8_t kLogicalPinForLeaf[kOutputLeafCount] = {3, 10, 9};
+
+void initializeOutputChannels(OutputChannels* channels);
+
+OutputGroup& groupForRole(OutputChannels* channels, OutputGroupRole role);
+
+void setGroupEnabled(OutputGroup* group, bool shouldBeEnabled);
+void setGroupAmplitude(OutputGroup* group, uint16_t amplitude);
+void setGroupToggleCount(OutputGroup* group, uint8_t toggleCount);
+
+// Removes the leaf from every group, then attaches it to the group named by the
+// routing character (b, p, v, i, g). Any other character, including n, leaves
+// the leaf unattached.
+void routeLeafToRoleCharacter(OutputChannels* channels, uint8_t leafIndex, uint8_t roleCharacter);
+
+// Requests every group disabled (used when the controller is not allowed to run).
+void disableAllGroups(OutputChannels* channels);
+
+// Qualified mains-phase boundary: reloads pending toggle counts from the
+// configured counts of enabled leaves and zeroes those of disabled leaves.
+void reloadPendingToggleCounts(OutputChannels* channels);
+
+}  // namespace meCoffee

+ 209 - 0
lib/meCoffee_controller_logic/src/persistent_settings_store.cpp

@@ -0,0 +1,209 @@
+#include "persistent_settings_store.h"
+
+#include "hardware_seam.h"
+
+namespace meCoffee {
+#if defined(MECOFFEE_RUST_PERSISTENCE)
+extern "C" {
+void mecoffee_rust_persistent_initialize();
+uint16_t mecoffee_rust_persistent_find(const uint8_t*, uint8_t);
+uint16_t mecoffee_rust_persistent_append(const uint8_t*, uint8_t, uint8_t);
+void mecoffee_rust_persistent_store(uint16_t, const uint8_t*);
+void mecoffee_rust_persistent_load(uint16_t, uint8_t*);
+void mecoffee_persistent_write(uint16_t address, uint8_t value) { hardware::writePersistentByte(address, value); }
+}
+namespace {
+uint8_t copyKey(const char* flash, uint8_t (&key)[10]) {
+    uint8_t length = 0;
+    while (length < sizeof key) {
+        const uint8_t value = hardware::readProgramMemoryByte(flash + length);
+        if (value == 0) break;
+        key[length++] = value;
+    }
+    return length;
+}
+}
+void ensurePersistentStoreIsInitialized() { mecoffee_rust_persistent_initialize(); }
+uint16_t findPersistentRecord(const char* key) {
+    uint8_t ram[10];
+    const uint8_t length = copyKey(key, ram);
+    return mecoffee_rust_persistent_find(ram, length);
+}
+uint16_t appendPersistentRecord(const char* key, uint8_t width) {
+    uint8_t ram[10];
+    const uint8_t length = copyKey(key, ram);
+    return mecoffee_rust_persistent_append(ram, length, width);
+}
+void storePersistentRecordPayload(uint16_t handle, const uint8_t* raw) { mecoffee_rust_persistent_store(handle, raw); }
+void loadPersistentRecordPayload(uint16_t handle, uint8_t* raw) { mecoffee_rust_persistent_load(handle, raw); }
+#else
+
+
+namespace {
+
+constexpr uint16_t kStoredNameBufferSize = 10;
+constexpr uint8_t kNumericRecordTag = 42;
+constexpr uint8_t kMaximumPayloadBytesCopied = 4;
+
+uint16_t readPersistentUnsigned16(uint16_t persistentAddress) {
+    return static_cast<uint16_t>(hardware::readPersistentByte(persistentAddress) |
+                                 (hardware::readPersistentByte(static_cast<uint16_t>(persistentAddress + 1)) << 8));
+}
+
+void writePersistentUnsigned16(uint16_t persistentAddress, uint16_t valueToStore) {
+    hardware::writePersistentByte(persistentAddress, static_cast<uint8_t>(valueToStore));
+    hardware::writePersistentByte(static_cast<uint16_t>(persistentAddress + 1), static_cast<uint8_t>(valueToStore >> 8));
+}
+
+uint16_t measureProgramMemoryStringLength(const char* programMemoryString) {
+    uint16_t length = 0;
+    while (hardware::readProgramMemoryByte(programMemoryString + length) != 0) {
+        ++length;
+    }
+    return length;
+}
+
+bool areMarkersValid() {
+    return hardware::readPersistentByte(1) == kPersistentStoreValidityMarker &&
+           hardware::readPersistentByte(2) == kPersistentStoreValidityMarker &&
+           hardware::readPersistentByte(kPersistentStoreCapacity - 1) == kPersistentStoreValidityMarker &&
+           hardware::readPersistentByte(kPersistentStoreCapacity - 2) == kPersistentStoreValidityMarker;
+}
+
+void resetPersistentStore() {
+    hardware::writePersistentByte(1, kPersistentStoreValidityMarker);
+    hardware::writePersistentByte(2, kPersistentStoreValidityMarker);
+    hardware::writePersistentByte(kPersistentStoreCapacity - 1, kPersistentStoreValidityMarker);
+    hardware::writePersistentByte(kPersistentStoreCapacity - 2, kPersistentStoreValidityMarker);
+    writePersistentUnsigned16(3, kFirstDirectoryAddress);
+    writePersistentUnsigned16(5, kInitialPayloadTailAddress);
+    for (uint16_t address = kFirstDirectoryAddress; address < kPersistentStoreCapacity - 2; ++address) {
+        hardware::writePersistentByte(address, 0);
+    }
+}
+
+}  // namespace
+
+void ensurePersistentStoreIsInitialized() {
+    if (!areMarkersValid()) {
+        resetPersistentStore();
+    }
+}
+
+uint16_t findPersistentRecord(const char* programMemoryKey) {
+    const int16_t directoryEndAddress = static_cast<int16_t>(readPersistentUnsigned16(3));
+    int16_t directoryCursor = kFirstDirectoryAddress;
+    while (directoryCursor < directoryEndAddress) {
+        char storedName[kStoredNameBufferSize] = {};
+        uint16_t storedNameLength = 0;
+        // The original reads names into a ten-byte buffer; stop at the buffer end.
+        while (storedNameLength < kStoredNameBufferSize) {
+            const uint8_t storedByte = hardware::readPersistentByte(static_cast<uint16_t>(directoryCursor));
+            ++directoryCursor;
+            if (storedByte == 0) {
+                break;
+            }
+            storedName[storedNameLength++] = static_cast<char>(storedByte);
+        }
+        bool isMatch = true;
+        uint16_t position = 0;
+        for (;; ++position) {
+            const uint8_t queriedByte = hardware::readProgramMemoryByte(programMemoryKey + position);
+            const uint8_t storedByte = position < storedNameLength ? static_cast<uint8_t>(storedName[position]) : 0;
+            if (queriedByte != storedByte) {
+                isMatch = false;
+                break;
+            }
+            if (queriedByte == 0) {
+                break;
+            }
+        }
+        if (isMatch) {
+            return static_cast<uint16_t>(directoryCursor);
+        }
+        directoryCursor = static_cast<int16_t>(directoryCursor + 4);
+    }
+    return kRecordHandleNotFound;
+}
+
+uint16_t appendPersistentRecord(const char* programMemoryKey, uint8_t payloadByteWidth) {
+    const uint16_t nextDirectoryAddress = readPersistentUnsigned16(3);
+    const uint16_t lastFreePayloadAddress = readPersistentUnsigned16(5);
+    const uint16_t keyLength = measureProgramMemoryStringLength(programMemoryKey);
+
+    for (uint16_t position = 0; position <= keyLength; ++position) {
+        hardware::writePersistentByte(static_cast<uint16_t>(nextDirectoryAddress + position),
+                                      hardware::readProgramMemoryByte(programMemoryKey + position));
+    }
+    const uint16_t recordHandle = static_cast<uint16_t>(nextDirectoryAddress + keyLength + 1);
+    const uint16_t payloadStart = static_cast<uint16_t>(lastFreePayloadAddress - (payloadByteWidth - 1));
+
+    writePersistentUnsigned16(recordHandle, payloadStart);
+    writePersistentUnsigned16(5, static_cast<uint16_t>(payloadStart - 1));
+    hardware::writePersistentByte(static_cast<uint16_t>(recordHandle + 2), payloadByteWidth);
+    hardware::writePersistentByte(static_cast<uint16_t>(recordHandle + 3), kNumericRecordTag);
+    writePersistentUnsigned16(3, static_cast<uint16_t>(recordHandle + 4));
+    return recordHandle;
+}
+
+void storePersistentRecordPayload(uint16_t recordHandle, const uint8_t* rawBytes) {
+    const uint16_t payloadStart = readPersistentUnsigned16(recordHandle);
+    const uint8_t storedWidth = hardware::readPersistentByte(static_cast<uint16_t>(recordHandle + 2));
+    for (uint8_t byteIndex = 0; byteIndex < storedWidth && byteIndex < kMaximumPayloadBytesCopied; ++byteIndex) {
+        hardware::writePersistentByte(static_cast<uint16_t>(payloadStart + byteIndex), rawBytes[byteIndex]);
+    }
+}
+
+void loadPersistentRecordPayload(uint16_t recordHandle, uint8_t* rawBytes) {
+    const uint16_t payloadStart = readPersistentUnsigned16(recordHandle);
+    const uint8_t storedWidth = hardware::readPersistentByte(static_cast<uint16_t>(recordHandle + 2));
+    for (uint8_t byteIndex = 0; byteIndex < storedWidth && byteIndex < kMaximumPayloadBytesCopied; ++byteIndex) {
+        rawBytes[byteIndex] = hardware::readPersistentByte(static_cast<uint16_t>(payloadStart + byteIndex));
+    }
+}
+
+#endif
+
+void registerAndRestoreSettings(SettingsValues* settings, uint16_t recordHandles[kSettingCount]) {
+    constexpr uint16_t kMaximumRestoredTwoByteValue = 32000;
+    constexpr uint32_t kMaximumRestoredScheduleValue = 86400;
+    assignDefaultSettings(settings);
+    for (uint8_t settingIndex = 0; settingIndex < kSettingCount; ++settingIndex) {
+        SettingDescriptor descriptor;
+        readSettingDescriptor(settingIndex, &descriptor);
+        const char* programMemoryKey = settingDescriptorKeyAddress(settingIndex);
+        uint8_t* storage = settingStorageAddress(settings, settingIndex);
+
+        uint16_t recordHandle = findPersistentRecord(programMemoryKey);
+        if (recordHandle == kRecordHandleNotFound) {
+            recordHandle = appendPersistentRecord(programMemoryKey, descriptor.byteWidth);
+            storePersistentRecordPayload(recordHandle, storage);
+        }
+        loadPersistentRecordPayload(recordHandle, storage);
+        recordHandles[settingIndex] = recordHandle;
+
+        bool isOutOfRange;
+        if (descriptor.byteWidth == 4) {
+            uint32_t restoredValue = 0;
+            for (uint8_t byteIndex = 0; byteIndex < 4; ++byteIndex) {
+                restoredValue |= static_cast<uint32_t>(storage[byteIndex]) << (8 * byteIndex);
+            }
+            isOutOfRange = restoredValue > kMaximumRestoredScheduleValue;
+        } else {
+            const uint16_t restoredValue = static_cast<uint16_t>(storage[0] | (storage[1] << 8));
+            isOutOfRange = restoredValue > kMaximumRestoredTwoByteValue;
+        }
+        if (isOutOfRange) {
+            for (uint8_t byteIndex = 0; byteIndex < descriptor.byteWidth; ++byteIndex) {
+                storage[byteIndex] = static_cast<uint8_t>(static_cast<uint32_t>(descriptor.defaultRawValue) >> (8 * byteIndex));
+            }
+            storePersistentRecordPayload(recordHandle, storage);
+        }
+    }
+}
+
+void persistSetting(SettingsValues* settings, const uint16_t recordHandles[kSettingCount], uint8_t settingIndex) {
+    storePersistentRecordPayload(recordHandles[settingIndex], settingStorageAddress(settings, settingIndex));
+}
+
+}  // namespace meCoffee

+ 41 - 0
lib/meCoffee_controller_logic/src/persistent_settings_store.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "settings_values.h"
+
+namespace meCoffee {
+
+constexpr uint16_t kPersistentStoreCapacity = 1024;
+constexpr uint8_t kPersistentStoreValidityMarker = 0x58;
+constexpr uint16_t kFirstDirectoryAddress = 7;
+constexpr uint16_t kInitialPayloadTailAddress = 1021;
+constexpr uint16_t kRecordHandleNotFound = 0;
+
+// Validates the four marker bytes (indices 1, 2, 1023, 1022, in that order) and
+// reinitializes the whole store when any differs. The reinitialization write
+// order is part of the on-device format and is preserved.
+void ensurePersistentStoreIsInitialized();
+
+// Returns the handle of the record named `programMemoryKey` or
+// kRecordHandleNotFound. The handle addresses the record's payload pointer.
+uint16_t findPersistentRecord(const char* programMemoryKey);
+
+// Appends a record and returns its handle. No capacity check, as in the original.
+uint16_t appendPersistentRecord(const char* programMemoryKey, uint8_t payloadByteWidth);
+
+// Writes the payload bytes named by the record's stored width, lowest byte first.
+void storePersistentRecordPayload(uint16_t recordHandle, const uint8_t* rawBytes);
+
+// Reads the payload bytes named by the record's stored width.
+void loadPersistentRecordPayload(uint16_t recordHandle, uint8_t* rawBytes);
+
+// Startup registration of all settings: finds or appends each record, restores
+// or initializes its payload, and replaces out-of-range restored values by the
+// default (persisting the replacement). `recordHandles` receives each handle.
+void registerAndRestoreSettings(SettingsValues* settings, uint16_t recordHandles[kSettingCount]);
+
+// Persists one setting through its record handle.
+void persistSetting(SettingsValues* settings, const uint16_t recordHandles[kSettingCount], uint8_t settingIndex);
+
+}  // namespace meCoffee

+ 122 - 0
lib/meCoffee_controller_logic/src/power_schedule.cpp

@@ -0,0 +1,122 @@
+#if !defined(MECOFFEE_RUST_SCHEDULE)
+#include "power_schedule.h"
+
+#include "diagnostic_queue.h"
+#include "heater_controller.h"
+#include "mains_events.h"
+#include "program_memory.h"
+
+namespace meCoffee {
+
+namespace {
+
+constexpr uint32_t kInactivityIntervalUnit = 60000;
+constexpr uint32_t kWakeExpiryDuration = 3600;
+
+void reportHeaterOff(FirmwareState* state) {
+    enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue, MECOFFEE_PROGRAM_TEXT("pid 0 0 0 0"));
+}
+
+void resetAccumulatorIfBelowSetpoint(FirmwareState* state) {
+    if (state->sensor.firstChannelAggregate < state->settings.brewSetpoint) {
+        resetIntegralAccumulator(state);
+    }
+}
+
+}  // namespace
+
+void initializePowerSchedule(FirmwareState* state) {
+    state->powerSchedule = PowerSchedule{};
+    state->powerSchedule.powerState = state->settings.timerControlledPowerInstallation == 0 ? 1 : 0;
+    state->powerSchedule.isControllerAllowed = state->powerSchedule.powerState != 0;
+}
+
+void handlePowerInputChange(FirmwareState* state, uint8_t argumentByte, uint32_t foregroundSnapshotMilliseconds) {
+    PowerSchedule& schedule = state->powerSchedule;
+    schedule.lastActivityTimestamp = foregroundSnapshotMilliseconds;
+    if (state->settings.powerInputFlipsState == 0) {
+        schedule.powerState = argumentByte;
+    } else if (argumentByte != 0) {
+        return;
+    } else {
+        schedule.powerState = schedule.powerState == 0 ? 1 : 0;
+    }
+
+    if (schedule.powerState == 0) {
+        reportHeaterOff(state);
+    } else {
+        schedule.wakeMarker = 0;
+        schedule.sleepMarker = 0;
+        resetAccumulatorIfBelowSetpoint(state);
+    }
+}
+
+void handleModeInputChange(FirmwareState* state, uint8_t argumentByte, uint32_t foregroundSnapshotMilliseconds) {
+    state->powerSchedule.lastActivityTimestamp = foregroundSnapshotMilliseconds;
+    state->alternateModeSelection = argumentByte;
+    if (argumentByte == 0) {
+        state->heater.bypassSelector = 0;
+    }
+    enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue,
+                                       argumentByte != 0 ? MECOFFEE_PROGRAM_TEXT("pid 65535 0 0 0") : MECOFFEE_PROGRAM_TEXT("pid 0 0 0 0"));
+}
+
+ScheduleDecision runScheduleStages(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds) {
+    PowerSchedule& schedule = state->powerSchedule;
+    const SettingsValues& settings = state->settings;
+    EventClock& clock = state->eventClock;
+    OutputGroup& boiler = groupForRole(&state->outputChannels, OutputGroupRole::Boiler);
+    const uint32_t clockSeconds = readEventClockSecondsAtomically(*state);
+
+    if (clock.isValid != 0 && (schedule.powerState == 0 || schedule.sleepMarker != 0) &&
+        settings.wakeTimerEnabled != 0 && schedule.wakeMarker == 0 && settings.wakeTimeSecondsSinceMidnight != 0 &&
+        clockSeconds == settings.wakeTimeSecondsSinceMidnight) {
+        schedule.wakeMarker = clockSeconds;
+        schedule.sleepMarker = 0;
+        setGroupEnabled(&boiler, true);
+        enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue, MECOFFEE_PROGRAM_TEXT("tmr Waked up"));
+    }
+
+    if (settings.inactivityShutdownEnabled != 0 && settings.inactivityShutdownInterval != 0 && schedule.sleepMarker == 0 &&
+        static_cast<uint32_t>(schedule.lastActivityTimestamp + kInactivityIntervalUnit * settings.inactivityShutdownInterval) <
+            foregroundSnapshotMilliseconds) {
+        schedule.lastActivityTimestamp = 0;
+        schedule.wakeMarker = 0;
+        schedule.powerState = 0;
+        schedule.sleepMarker = clockSeconds;
+        setGroupEnabled(&boiler, false);
+        reportHeaterOff(state);
+        enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue, MECOFFEE_PROGRAM_TEXT("tmr Shut off due to inactivity"));
+    }
+
+    if (clock.isValid != 0 && (schedule.powerState != 0 || schedule.wakeMarker != 0) && settings.sleepTimerEnabled != 0 &&
+        schedule.sleepMarker == 0 && settings.sleepTimeSecondsSinceMidnight != 0 &&
+        clockSeconds == settings.sleepTimeSecondsSinceMidnight) {
+        schedule.sleepMarker = clockSeconds;
+        schedule.wakeMarker = 0;
+        setGroupEnabled(&boiler, false);
+        reportHeaterOff(state);
+        enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue, MECOFFEE_PROGRAM_TEXT("tmr Shut off"));
+    }
+
+    if (schedule.wakeMarker != 0 && schedule.powerState == 0 &&
+        static_cast<uint32_t>(clockSeconds - schedule.wakeMarker) >= kWakeExpiryDuration) {
+        schedule.wakeMarker = 0;
+        setGroupEnabled(&boiler, false);
+        reportHeaterOff(state);
+        enqueueDiagnosticProgramMemoryText(&state->diagnosticQueue,
+                                           MECOFFEE_PROGRAM_TEXT("tmr Waked, but went to sleep again after 1 hour"));
+    }
+
+    ScheduleDecision decision;
+    decision.isControllerAllowed = (schedule.powerState != 0 || schedule.wakeMarker != 0) && schedule.sleepMarker == 0;
+    schedule.isControllerAllowed = decision.isControllerAllowed;
+    if (!decision.isControllerAllowed) {
+        disableAllGroups(&state->outputChannels);
+    }
+    return decision;
+}
+
+}  // namespace meCoffee
+
+#endif

+ 27 - 0
lib/meCoffee_controller_logic/src/power_schedule.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// Result of the foreground scheduler stages for this pass.
+struct ScheduleDecision {
+    bool isControllerAllowed;
+};
+
+// Power-input callback. Always refreshes the activity time stamp first.
+void handlePowerInputChange(FirmwareState* state, uint8_t argumentByte, uint32_t foregroundSnapshotMilliseconds);
+
+// Alternate-mode (steam) input callback.
+void handleModeInputChange(FirmwareState* state, uint8_t argumentByte, uint32_t foregroundSnapshotMilliseconds);
+
+// Start-up power state: powered when `timerControlledPowerInstallation` is zero.
+void initializePowerSchedule(FirmwareState* state);
+
+// Wake, inactivity, scheduled sleep and wake-expiry stages, in that order, then the
+// controller gate. Disables all output groups when the controller is not allowed.
+ScheduleDecision runScheduleStages(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds);
+
+}  // namespace meCoffee

+ 14 - 0
lib/meCoffee_controller_logic/src/program_memory.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include <stdint.h>
+
+#if defined(__AVR__)
+#define MECOFFEE_PROGRAM_MEMORY __attribute__((__progmem__))
+#else
+#define MECOFFEE_PROGRAM_MEMORY
+#endif
+
+// Yields a `const char*` to a constant string stored in program memory on the
+// target. Read it with hardware::readProgramMemoryByte or the serial_output helpers.
+#define MECOFFEE_PROGRAM_TEXT(literalText) \
+    (__extension__({ static const char programTextStorage[] MECOFFEE_PROGRAM_MEMORY = (literalText); &programTextStorage[0]; }))

+ 39 - 0
lib/meCoffee_controller_logic/src/pulse_tick.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// One tick of the fast pulse timer. Advances the phase counter and returns a mask
+// with bit n set when output leaf n must toggle. Also decides whether an analog
+// conversion should start (only while the qualified-event count is odd and no
+// conversion is pending). Interrupt context: no calls, no blocking.
+struct PulseTickResult {
+    uint8_t toggleMask;
+    bool shouldStartConversion;
+};
+
+__attribute__((always_inline)) inline PulseTickResult advancePulseTick(FirmwareState* state) {
+    const uint16_t ticksSinceQualifiedEvent = static_cast<uint16_t>(state->mainsPhase.ticksSinceQualifiedEvent + 1);
+    state->mainsPhase.ticksSinceQualifiedEvent = ticksSinceQualifiedEvent;
+
+    PulseTickResult result;
+    result.toggleMask = 0;
+#pragma GCC unroll 3
+    for (uint8_t leafIndex = 0; leafIndex < kOutputLeafCount; ++leafIndex) {
+        OutputLeaf& leaf = state->outputChannels.leaves[leafIndex];
+        if (leaf.pendingToggleCount != 0 && ticksSinceQualifiedEvent >= leaf.startThreshold) {
+            leaf.pendingToggleCount = static_cast<uint8_t>(leaf.pendingToggleCount - 1);
+            result.toggleMask = static_cast<uint8_t>(result.toggleMask | (1u << leafIndex));
+        }
+    }
+    result.shouldStartConversion = state->sensor.isConversionPending == 0 && (state->mainsPhase.qualifiedEventCount & 1) != 0;
+    if (result.shouldStartConversion) {
+        state->sensor.isConversionPending = 1;
+    }
+    return result;
+}
+
+}  // namespace meCoffee

+ 147 - 0
lib/meCoffee_controller_logic/src/sensor_sampling.cpp

@@ -0,0 +1,147 @@
+#include "sensor_sampling.h"
+
+#include "hardware_seam.h"
+
+namespace meCoffee {
+#if defined(MECOFFEE_RUST_SENSOR)
+extern "C" uint16_t mecoffee_rust_sensor_transition(uint8_t, uint8_t, uint8_t);
+extern "C" uint16_t mecoffee_rust_sensor_aggregate(uint16_t*, uint8_t*, uint8_t, uint8_t);
+extern "C" void mecoffee_sensor_range_pin(uint8_t selector, uint8_t high) {
+    hardware::writeRangeIndicatorPin(selector, high != 0);
+}
+#endif
+
+
+namespace {
+
+constexpr uint8_t kCentralWindowStart = 8;
+constexpr uint8_t kCentralWindowEnd = 24;
+constexpr uint32_t kAggregateScale = 22000;
+constexpr uint16_t kRangeIncreaseThreshold = 10901;
+constexpr uint16_t kRangeDecreaseThreshold = 10700;
+// The original consumes a finished conversion once per foreground pass, so its sample rate follows
+// the pass time, which depends on the controller path (measured in simulation, per mains-gated
+// second: 457 samples with the continuous controller, 1118 to 1287 on the light paths).
+constexpr uint32_t kHeavyPassSampleIntervalMicroseconds = 1135;
+constexpr uint32_t kLightPassSampleIntervalMicroseconds = 430;
+
+bool isControllerOnLightPath(const FirmwareState& state) {
+    const bool isBelowUsableAggregate = state.sensor.firstChannelAggregate < 100;
+    const bool isSteamBypass = state.alternateModeSelection != 0 && state.heater.bypassSelector == 0;
+    const bool isDiscreteControl = state.settings.continuousHeaterControlEnabled == 0;
+    const bool isControllerBlocked = !state.powerSchedule.isControllerAllowed;
+    return isBelowUsableAggregate || isSteamBypass || isDiscreteControl || isControllerBlocked;
+}
+
+#if !defined(MECOFFEE_RUST_SENSOR)
+void sortAscending(uint16_t* ring) {
+    for (uint8_t position = 1; position < kSensorRingLength; ++position) {
+        const uint16_t movingValue = ring[position];
+        int8_t insertionPosition = static_cast<int8_t>(position) - 1;
+        while (insertionPosition >= 0 && ring[insertionPosition] > movingValue) {
+            ring[insertionPosition + 1] = ring[insertionPosition];
+            --insertionPosition;
+        }
+        ring[insertionPosition + 1] = movingValue;
+    }
+}
+
+
+#endif
+}  // namespace
+
+uint8_t storeConversionResult(FirmwareState* state, uint16_t conversionResult) {
+    SensorState& sensor = state->sensor;
+#if defined(MECOFFEE_RUST_SENSOR)
+    const uint16_t transition = mecoffee_rust_sensor_transition(sensor.ringWriteIndex,
+        sensor.currentChannel, state->settings.secondSensorChannelEnabled != 0);
+    const uint8_t nextIndex = static_cast<uint8_t>(transition);
+    uint16_t* ring = (sensor.currentChannel & 1) != 0 ? sensor.firstRing : sensor.secondRing;
+    ring[nextIndex & (kSensorRingLength - 1)] = conversionResult;
+    sensor.ringWriteIndex = nextIndex;
+    return static_cast<uint8_t>(transition >> 8);
+#else
+    if ((sensor.currentChannel & 1) != 0) {
+        sensor.firstRing[(sensor.ringWriteIndex + 1) & (kSensorRingLength - 1)] = conversionResult;
+        sensor.ringWriteIndex = static_cast<uint8_t>(sensor.ringWriteIndex + 1);
+        return state->settings.secondSensorChannelEnabled != 0 ? kSecondTemperatureChannel : sensor.currentChannel;
+    }
+    sensor.secondRing[sensor.ringWriteIndex & (kSensorRingLength - 1)] = conversionResult;
+    return kFirstTemperatureChannel;
+#endif
+}
+
+uint16_t aggregateSensorRing(FirmwareState* state, uint16_t* ring, uint8_t rangeSelector) {
+    SensorState& sensor = state->sensor;
+    uint8_t& rangeFlag = rangeSelector == 0 ? sensor.firstRangeFlag : sensor.secondRangeFlag;
+
+#if defined(MECOFFEE_RUST_SENSOR)
+    return mecoffee_rust_sensor_aggregate(ring, &rangeFlag, sensor.isRangeMaintenanceDisabled, rangeSelector);
+#else
+    sortAscending(ring);
+    uint16_t centralSum = 0;
+    for (uint8_t position = kCentralWindowStart; position < kCentralWindowEnd; ++position) {
+        centralSum = static_cast<uint16_t>(centralSum + ring[position]);
+    }
+
+    const uint32_t scaledProduct = static_cast<uint32_t>(centralSum) * kAggregateScale;
+    const uint8_t shift = rangeFlag == 0 ? 15 : 14;
+    const uint32_t roundedAggregate = (scaledProduct >> shift) + ((scaledProduct >> (shift - 1)) & 1u);
+    const uint16_t aggregateBeforeOffset = static_cast<uint16_t>(roundedAggregate);
+
+    if (sensor.isRangeMaintenanceDisabled == 0) {
+        if (rangeFlag == 0 && aggregateBeforeOffset >= kRangeIncreaseThreshold) {
+            rangeFlag = 1;
+            for (uint8_t position = 0; position < kSensorRingLength; ++position) {
+                ring[position] = static_cast<uint16_t>(ring[position] >> 1);
+            }
+            hardware::writeRangeIndicatorPin(rangeSelector, true);
+        } else if (rangeFlag != 0 && aggregateBeforeOffset < kRangeDecreaseThreshold) {
+            rangeFlag = 0;
+            for (uint8_t position = 0; position < kSensorRingLength; ++position) {
+                ring[position] = static_cast<uint16_t>(ring[position] << 1);
+            }
+            hardware::writeRangeIndicatorPin(rangeSelector, false);
+        }
+    }
+
+    return roundedAggregate != 0 ? static_cast<uint16_t>(aggregateBeforeOffset + 2) : 0;
+#endif
+}
+
+void initializeSensorSampling(FirmwareState* state) {
+    SensorState& sensor = state->sensor;
+    sensor.currentChannel = kFirstTemperatureChannel;
+    hardware::selectAnalogChannel(kFirstTemperatureChannel);
+    hardware::startAnalogConversion();
+    while (hardware::isAnalogConversionRunning()) {
+    }
+    storeConversionResult(state, hardware::readAnalogConversionResult());
+    const uint16_t seedSample = sensor.firstRing[1];
+    for (uint8_t position = 0; position < kSensorRingLength; ++position) {
+        sensor.firstRing[position] = seedSample;
+    }
+    sensor.isConversionPending = 0;
+    sensor.startupAggregate = static_cast<int16_t>(aggregateSensorRing(state, sensor.firstRing, 0));
+}
+
+void consumeFinishedConversion(FirmwareState* state) {
+    SensorState& sensor = state->sensor;
+    if (sensor.isConversionPending == 0 || hardware::isAnalogConversionRunning()) {
+        return;
+    }
+    // Pacing keeps the 32-sample ring spanning the same time as in the original, which sets how
+    // smooth and how delayed the aggregate is.
+    const uint32_t now = hardware::readMicrosecondCounter();
+    if (static_cast<uint32_t>(now - sensor.lastConsumeMicroseconds) < (isControllerOnLightPath(*state) ? kLightPassSampleIntervalMicroseconds : kHeavyPassSampleIntervalMicroseconds)) {
+        return;
+    }
+    sensor.lastConsumeMicroseconds = now;
+    const uint16_t conversionResult = hardware::readAnalogConversionResult();
+    const uint8_t nextChannel = storeConversionResult(state, conversionResult);
+    sensor.currentChannel = nextChannel;
+    hardware::selectAnalogChannel(nextChannel);
+    sensor.isConversionPending = 0;
+}
+
+}  // namespace meCoffee

+ 31 - 0
lib/meCoffee_controller_logic/src/sensor_sampling.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+constexpr uint8_t kFirstTemperatureChannel = 3;
+constexpr uint8_t kSecondTemperatureChannel = 4;
+
+// Stores one finished conversion in the ring selected by the current channel and
+// returns the channel to select next. Channel 3 writes the first ring at
+// (index + 1) & 31 and increments the index; channel 4 writes the second ring at
+// index & 31. The next channel is 4 only when the second channel is enabled.
+uint8_t storeConversionResult(FirmwareState* state, uint16_t conversionResult);
+
+// Sorts the ring ascending, sums positions 8..23 and returns the scaled,
+// range-maintained aggregate (low 16 bits). `rangeSelector` is 0 for the first
+// ring and 1 for the second.
+uint16_t aggregateSensorRing(FirmwareState* state, uint16_t* ring, uint8_t rangeSelector);
+
+// Start-up: waits for one conversion, fills the first ring with it and stores the
+// resulting aggregate in `startupAggregate`.
+void initializeSensorSampling(FirmwareState* state);
+
+// Foreground half of the acquisition: when a started conversion has finished,
+// stores it and selects the next channel.
+void consumeFinishedConversion(FirmwareState* state);
+
+}  // namespace meCoffee

+ 56 - 0
lib/meCoffee_controller_logic/src/serial_output.cpp

@@ -0,0 +1,56 @@
+#include "serial_output.h"
+
+#include "hardware_seam.h"
+#include "numeric_conversion.h"
+#include "program_memory.h"
+
+namespace meCoffee {
+
+void writeRamText(const char* ramText) {
+    if (ramText == nullptr) {
+        return;
+    }
+    while (*ramText != '\0') {
+        hardware::writeSerialByte(static_cast<uint8_t>(*ramText));
+        ++ramText;
+    }
+}
+
+void writeProgramMemoryText(const char* programMemoryText) {
+    if (programMemoryText == nullptr) {
+        return;
+    }
+    for (;;) {
+        const uint8_t character = hardware::readProgramMemoryByte(programMemoryText);
+        if (character == 0) {
+            return;
+        }
+        hardware::writeSerialByte(character);
+        ++programMemoryText;
+    }
+}
+
+void writeSpace() { hardware::writeSerialByte(' '); }
+
+void writeCarriageReturnLineFeed() {
+    hardware::writeSerialByte('\r');
+    hardware::writeSerialByte('\n');
+}
+
+void writeSignedDecimalText(int32_t numericValue) {
+    char digits[12];
+    const uint8_t length = formatSignedDecimal(numericValue, digits);
+    for (uint8_t position = 0; position < length; ++position) {
+        hardware::writeSerialByte(static_cast<uint8_t>(digits[position]));
+    }
+}
+
+void writeUnsignedDecimalText(uint32_t numericValue) {
+    char digits[12];
+    const uint8_t length = formatUnsignedDecimal(numericValue, digits);
+    for (uint8_t position = 0; position < length; ++position) {
+        hardware::writeSerialByte(static_cast<uint8_t>(digits[position]));
+    }
+}
+
+}  // namespace meCoffee

+ 14 - 0
lib/meCoffee_controller_logic/src/serial_output.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+void writeRamText(const char* ramText);
+void writeProgramMemoryText(const char* programMemoryText);
+void writeSpace();
+void writeCarriageReturnLineFeed();
+void writeSignedDecimalText(int32_t numericValue);
+void writeUnsignedDecimalText(uint32_t numericValue);
+
+}  // namespace meCoffee

+ 113 - 0
lib/meCoffee_controller_logic/src/settings_values.cpp

@@ -0,0 +1,113 @@
+#include "settings_values.h"
+
+#include <stddef.h>
+
+#include "hardware_seam.h"
+#include "program_memory.h"
+
+namespace meCoffee {
+
+namespace {
+
+#define MECOFFEE_SETTING(protocolKey, memberName, byteWidth, isSigned, defaultRawValue) \
+    {protocolKey, static_cast<uint8_t>(offsetof(SettingsValues, memberName)), byteWidth, isSigned, defaultRawValue}
+
+const SettingDescriptor kSettingDescriptors[kSettingCount] MECOFFEE_PROGRAM_MEMORY = {
+    MECOFFEE_SETTING("pd1p", proportionalGain, 2, false, 20),
+    MECOFFEE_SETTING("pd1i", integralGain, 2, false, 30),
+    MECOFFEE_SETTING("pd1d", derivativeGain, 2, false, 128),
+    MECOFFEE_SETTING("pd1lck", shotFeedForwardLockDuration, 2, false, 120),
+    MECOFFEE_SETTING("pd1imn", integralAccumulatorMinimum, 2, false, 1000),
+    MECOFFEE_SETTING("pd1imx", integralAccumulatorMaximum, 2, false, 13107),
+    MECOFFEE_SETTING("tmpsp", brewSetpoint, 2, true, 10100),
+    MECOFFEE_SETTING("tmpstm", steamSetpoint, 2, true, 12500),
+    MECOFFEE_SETTING("pinbl", preinfusionEnabled, 2, false, 0),
+    MECOFFEE_SETTING("pivlv", preinfusionValveClosed, 2, false, 0),
+    MECOFFEE_SETTING("tmpcntns", continuousHeaterControlEnabled, 2, false, 1),
+    MECOFFEE_SETTING("tmrwnbl", wakeTimerEnabled, 2, false, 0),
+    MECOFFEE_SETTING("tmrsnbl", sleepTimerEnabled, 2, false, 0),
+    MECOFFEE_SETTING("tmrpwr", timerControlledPowerInstallation, 2, false, 0),
+    MECOFFEE_SETTING("shtmx", maximumShotDuration, 2, false, 0),
+    MECOFFEE_SETTING("pwrflp", powerInputFlipsState, 2, false, 0),
+    MECOFFEE_SETTING("tmron", wakeTimeSecondsSinceMidnight, 4, false, 0),
+    MECOFFEE_SETTING("tmroff", sleepTimeSecondsSinceMidnight, 4, false, 0),
+    MECOFFEE_SETTING("tmppap", shotFeedForwardPercentage, 2, false, 33),
+    MECOFFEE_SETTING("cntgrndr", grinderCounter, 2, false, 0),
+    MECOFFEE_SETTING("btstp", bluetoothModuleSetupState, 2, false, 0),
+    MECOFFEE_SETTING("pistrt", preinfusionPumpDuration, 2, false, 3000),
+    MECOFFEE_SETTING("piprd", preinfusionPauseDuration, 2, false, 3000),
+    MECOFFEE_SETTING("grnd", grinderSetting, 2, false, 1000),
+    MECOFFEE_SETTING("pp1", pressureProfileStart, 2, false, 100),
+    MECOFFEE_SETTING("pp2", pressureProfileEnd, 2, false, 100),
+    MECOFFEE_SETTING("ppt", pressureProfileDuration, 2, false, 25),
+    MECOFFEE_SETTING("s2cr", secondSensorCalibration, 2, true, 0),
+    MECOFFEE_SETTING("pd1sz", controllerUpdatePeriod, 2, false, 1000),
+    MECOFFEE_SETTING("tmrosdnbl", inactivityShutdownEnabled, 2, false, 0),
+    MECOFFEE_SETTING("tmrosd", inactivityShutdownInterval, 2, false, 60),
+    MECOFFEE_SETTING("o0", outputZeroRoleCode, 2, false, 112),
+    MECOFFEE_SETTING("o1", outputOneRoleCode, 2, false, 98),
+    MECOFFEE_SETTING("o2", outputTwoRoleCode, 2, false, 118),
+    MECOFFEE_SETTING("s1nb", secondSensorChannelEnabled, 2, false, 0),
+};
+
+#undef MECOFFEE_SETTING
+
+}  // namespace
+
+void readSettingDescriptor(uint8_t settingIndex, SettingDescriptor* descriptorOutput) {
+    const uint8_t* source = reinterpret_cast<const uint8_t*>(&kSettingDescriptors[settingIndex]);
+    uint8_t* destination = reinterpret_cast<uint8_t*>(descriptorOutput);
+    for (size_t byteIndex = 0; byteIndex < sizeof(SettingDescriptor); ++byteIndex) {
+        destination[byteIndex] = hardware::readProgramMemoryByte(source + byteIndex);
+    }
+}
+
+const char* settingDescriptorKeyAddress(uint8_t settingIndex) { return kSettingDescriptors[settingIndex].protocolKey; }
+
+int8_t findSettingIndexByProtocolKey(const char* protocolKey) {
+    for (uint8_t settingIndex = 0; settingIndex < kSettingCount; ++settingIndex) {
+        SettingDescriptor descriptor;
+        readSettingDescriptor(settingIndex, &descriptor);
+        uint8_t position = 0;
+        while (descriptor.protocolKey[position] != '\0' && descriptor.protocolKey[position] == protocolKey[position]) {
+            ++position;
+        }
+        if (descriptor.protocolKey[position] == '\0' && protocolKey[position] == '\0') {
+            return static_cast<int8_t>(settingIndex);
+        }
+    }
+    return -1;
+}
+
+void assignDefaultSettings(SettingsValues* settings) {
+    for (uint8_t settingIndex = 0; settingIndex < kSettingCount; ++settingIndex) {
+        SettingDescriptor descriptor;
+        readSettingDescriptor(settingIndex, &descriptor);
+        uint8_t* storage = settingStorageAddress(settings, settingIndex);
+        for (uint8_t byteIndex = 0; byteIndex < descriptor.byteWidth; ++byteIndex) {
+            storage[byteIndex] = static_cast<uint8_t>(static_cast<uint32_t>(descriptor.defaultRawValue) >> (8 * byteIndex));
+        }
+    }
+}
+
+uint8_t* settingStorageAddress(SettingsValues* settings, uint8_t settingIndex) {
+    SettingDescriptor descriptor;
+    readSettingDescriptor(settingIndex, &descriptor);
+    return reinterpret_cast<uint8_t*>(settings) + descriptor.memberOffset;
+}
+
+int32_t readSettingForDisplay(const SettingsValues& settings, uint8_t settingIndex) {
+    SettingDescriptor descriptor;
+    readSettingDescriptor(settingIndex, &descriptor);
+    const uint8_t* storage = reinterpret_cast<const uint8_t*>(&settings) + descriptor.memberOffset;
+    if (descriptor.byteWidth == 4) {
+        uint32_t assembledValue = 0;
+        for (uint8_t byteIndex = 0; byteIndex < 4; ++byteIndex) {
+            assembledValue |= static_cast<uint32_t>(storage[byteIndex]) << (8 * byteIndex);
+        }
+        return static_cast<int32_t>(assembledValue);
+    }
+    return static_cast<int16_t>(static_cast<uint16_t>(storage[0] | (storage[1] << 8)));
+}
+
+}  // namespace meCoffee

+ 79 - 0
lib/meCoffee_controller_logic/src/settings_values.h

@@ -0,0 +1,79 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee {
+
+// Raw (unscaled) setting values exactly as the original image stores them.
+// Member names describe the meaning; `protocolKey` in the descriptor table is the
+// persisted and on-the-wire name that existing clients and stored data depend on.
+struct SettingsValues {
+    uint16_t proportionalGain;
+    uint16_t integralGain;
+    uint16_t derivativeGain;
+    uint16_t shotFeedForwardLockDuration;
+    uint16_t integralAccumulatorMinimum;
+    uint16_t integralAccumulatorMaximum;
+    int16_t brewSetpoint;
+    int16_t steamSetpoint;
+    uint16_t preinfusionEnabled;
+    uint16_t preinfusionValveClosed;
+    uint16_t continuousHeaterControlEnabled;
+    uint16_t wakeTimerEnabled;
+    uint16_t sleepTimerEnabled;
+    uint16_t timerControlledPowerInstallation;
+    uint16_t maximumShotDuration;
+    uint16_t powerInputFlipsState;
+    uint32_t wakeTimeSecondsSinceMidnight;
+    uint32_t sleepTimeSecondsSinceMidnight;
+    uint16_t shotFeedForwardPercentage;
+    uint16_t grinderCounter;
+    uint16_t bluetoothModuleSetupState;
+    uint16_t preinfusionPumpDuration;
+    uint16_t preinfusionPauseDuration;
+    uint16_t grinderSetting;
+    uint16_t pressureProfileStart;
+    uint16_t pressureProfileEnd;
+    uint16_t pressureProfileDuration;
+    int16_t secondSensorCalibration;
+    uint16_t controllerUpdatePeriod;
+    uint16_t inactivityShutdownEnabled;
+    uint16_t inactivityShutdownInterval;
+    uint16_t outputZeroRoleCode;
+    uint16_t outputOneRoleCode;
+    uint16_t outputTwoRoleCode;
+    uint16_t secondSensorChannelEnabled;
+};
+
+constexpr uint8_t kSettingCount = 35;
+constexpr uint8_t kMaximumProtocolKeyLength = 9;
+
+struct SettingDescriptor {
+    char protocolKey[kMaximumProtocolKeyLength + 1];
+    uint8_t memberOffset;
+    uint8_t byteWidth;
+    bool isSigned;
+    int32_t defaultRawValue;
+};
+
+// Descriptors in the original registration order; that order is also the
+// `dump` order and the order of records in the persistent store.
+// Copies the descriptor out of program memory.
+void readSettingDescriptor(uint8_t settingIndex, SettingDescriptor* descriptorOutput);
+
+// Program-memory address of the setting's protocol key string.
+const char* settingDescriptorKeyAddress(uint8_t settingIndex);
+
+// Returns the index of the setting with this protocol key, or -1. The comparison
+// matches the full key; both strings are RAM strings.
+int8_t findSettingIndexByProtocolKey(const char* protocolKey);
+
+void assignDefaultSettings(SettingsValues* settings);
+
+uint8_t* settingStorageAddress(SettingsValues* settings, uint8_t settingIndex);
+
+// Signed/unsigned interpretation used when printing: two-byte values print as
+// signed 16-bit, four-byte values as signed 32-bit.
+int32_t readSettingForDisplay(const SettingsValues& settings, uint8_t settingIndex);
+
+}  // namespace meCoffee

+ 219 - 0
lib/meCoffee_controller_logic/src/shot_control.cpp

@@ -0,0 +1,219 @@
+#include "shot_control.h"
+
+#include "diagnostic_queue.h"
+#include "heater_controller.h"
+#include "power_schedule.h"
+#include "program_memory.h"
+#include "serial_output.h"
+
+#if defined(MECOFFEE_RUST_SHOT)
+#include <avr/io.h>
+#include <avr/interrupt.h>
+#include <stddef.h>
+struct RustShotSettings {uint16_t enabled,closed,pump,pause,maximum;};
+extern "C" {
+void mecoffee_rust_shot_event(meCoffee::ShotState*,uint8_t,uint32_t,uint32_t,void*);
+void mecoffee_rust_shot_active(meCoffee::ShotState*,const RustShotSettings*,uint8_t,uint32_t,uint32_t,void*);
+void mecoffee_rust_poll_input(meCoffee::DebouncedInput*,uint8_t,uint8_t,uint32_t,void*);
+uint16_t mecoffee_rust_shot_layout(uint8_t);
+}
+static_assert(sizeof(meCoffee::ShotState)==5,"shot state ABI");
+static_assert(sizeof(meCoffee::DebouncedInput)==15,"input state ABI");
+static_assert(offsetof(meCoffee::DebouncedInput,beginTimestamp)==3,"input begin ABI");
+#endif
+
+namespace meCoffee {
+
+namespace {
+
+#if defined(MECOFFEE_RUST_TELEMETRY)
+extern "C" void mecoffee_rust_telemetry(void*,uint8_t);
+void reportShotBegin(FirmwareState& state) { mecoffee_rust_telemetry(&state,3); }
+void reportShotEnd(FirmwareState& state) { mecoffee_rust_telemetry(&state,4); }
+#else
+void reportShotBegin(FirmwareState& state) {
+    if (state.alternateModeSelection != 0) {
+        return;
+    }
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("sht "));
+    writeUnsignedDecimalText(state.sensor.aggregateGeneration);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" 0 OK"));
+    writeCarriageReturnLineFeed();
+}
+
+void reportShotEnd(FirmwareState& state) {
+    if (state.alternateModeSelection != 0) {
+        return;
+    }
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("sht "));
+    writeUnsignedDecimalText(state.sensor.aggregateGeneration);
+    writeSpace();
+    writeUnsignedDecimalText(state.shot.lastShotDuration);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+}
+#endif
+
+
+void beginShotEvent(FirmwareState* state, uint32_t timestamp) {
+#if defined(MECOFFEE_RUST_SHOT)
+    mecoffee_rust_shot_event(&state->shot,0,timestamp,0,state);
+#else
+    // Observed in the original: a shot begin counts as user activity for the inactivity timer.
+    state->powerSchedule.lastActivityTimestamp = timestamp;
+    state->shot.isTimeoutEventLatched = false;
+    beginShotFeedForward(state, timestamp);
+    enqueueDiagnosticCallback(&state->diagnosticQueue, reportShotBegin);
+#endif
+}
+
+void endShotEvent(FirmwareState* state, uint32_t endTimestamp, uint32_t beginTimestamp) {
+#if defined(MECOFFEE_RUST_SHOT)
+    mecoffee_rust_shot_event(&state->shot,1,endTimestamp,beginTimestamp,state);
+#else
+    setGroupEnabled(&groupForRole(&state->outputChannels, OutputGroupRole::Pump), false);
+    setGroupEnabled(&groupForRole(&state->outputChannels, OutputGroupRole::Valve), false);
+    state->shot.lastShotDuration = endTimestamp - beginTimestamp;
+    endShotFeedForward(state, endTimestamp);
+    enqueueDiagnosticCallback(&state->diagnosticQueue, reportShotEnd);
+#endif
+}
+
+void runShotActiveCallback(FirmwareState* state, uint32_t foregroundSnapshotMilliseconds, uint32_t beginTimestamp) {
+#if defined(MECOFFEE_RUST_SHOT)
+    const auto& s=state->settings;
+    const RustShotSettings settings={s.preinfusionEnabled,s.preinfusionValveClosed,s.preinfusionPumpDuration,s.preinfusionPauseDuration,s.maximumShotDuration};
+    mecoffee_rust_shot_active(&state->shot,&settings,state->alternateModeSelection,foregroundSnapshotMilliseconds,beginTimestamp,state);
+#else
+    const uint32_t elapsed = foregroundSnapshotMilliseconds - beginTimestamp;
+    const ShotOutputRequest request = evaluateShotOutputRequest(state->settings, state->alternateModeSelection, elapsed);
+    OutputGroup& pump = groupForRole(&state->outputChannels, OutputGroupRole::Pump);
+    OutputGroup& valve = groupForRole(&state->outputChannels, OutputGroupRole::Valve);
+    if (request.isTimedOut) {
+        setGroupEnabled(&pump, false);
+        setGroupEnabled(&valve, false);
+        if (!state->shot.isTimeoutEventLatched) {
+            state->shot.isTimeoutEventLatched = true;
+            endShotEvent(state, foregroundSnapshotMilliseconds, beginTimestamp);
+        }
+        return;
+    }
+    state->shot.isTimeoutEventLatched = false;
+    setGroupEnabled(&pump, request.isPumpRequested);
+    setGroupEnabled(&valve, request.isValveRequested);
+#endif
+}
+
+// Begin (argument 1) and end (argument 0) of an input burst.
+void runInputStateChange(FirmwareState* state, InputRole role, uint8_t argument, uint32_t timestamp, uint32_t beginTimestamp) {
+    switch (role) {
+        case InputRole::Power:
+            handlePowerInputChange(state, argument, timestamp);
+            break;
+        case InputRole::Mode:
+            handleModeInputChange(state, argument, timestamp);
+            break;
+        case InputRole::Shot:
+            if (argument != 0) {
+                beginShotEvent(state, timestamp);
+            } else {
+                endShotEvent(state, timestamp, beginTimestamp);
+            }
+            break;
+    }
+}
+
+}  // namespace
+
+bool isInputQualified(const DebouncedInput& input) {
+    return input.risingEdgeCount >= kRequiredEdgesPerLevel && input.fallingEdgeCount >= kRequiredEdgesPerLevel;
+}
+
+void initializeDebouncedInput(DebouncedInput* input) {
+#if defined(MECOFFEE_RUST_SHOT)
+    if(mecoffee_rust_shot_layout(0)!=5 || mecoffee_rust_shot_layout(1)!=15 || mecoffee_rust_shot_layout(2)!=10 || mecoffee_rust_shot_layout(3)!=3) {cli();for(;;){}}
+#endif
+    *input = DebouncedInput{};
+    input->isLastLevelHigh = true;
+}
+
+ShotOutputRequest evaluateShotOutputRequest(const SettingsValues& settings, uint8_t alternateMode, uint32_t elapsedMilliseconds) {
+    const uint16_t gapEnd = static_cast<uint16_t>(settings.preinfusionPumpDuration + settings.preinfusionPauseDuration);
+    const bool isInGap = settings.preinfusionEnabled != 0 && elapsedMilliseconds > settings.preinfusionPumpDuration &&
+                         elapsedMilliseconds < gapEnd;
+    const uint16_t timeoutThreshold = static_cast<uint16_t>(static_cast<uint32_t>(settings.maximumShotDuration) * 1000u);
+    ShotOutputRequest request;
+    request.isTimedOut = settings.maximumShotDuration != 0 && elapsedMilliseconds > timeoutThreshold;
+    request.isPumpRequested = !isInGap && !request.isTimedOut;
+    request.isValveRequested = !request.isTimedOut && alternateMode == 0 && (!isInGap || settings.preinfusionValveClosed == 0);
+    return request;
+}
+
+void pollDebouncedInput(FirmwareState* state, DebouncedInput* input, InputRole role, bool isPinHigh,
+                        uint32_t foregroundSnapshotMilliseconds) {
+#if defined(MECOFFEE_RUST_SHOT)
+    mecoffee_rust_poll_input(input,static_cast<uint8_t>(role),isPinHigh,foregroundSnapshotMilliseconds,state);
+#else
+    if (isPinHigh != input->isLastLevelHigh) {
+        input->isLastLevelHigh = isPinHigh;
+        input->lastChangeTimestamp = foregroundSnapshotMilliseconds;
+        uint8_t& edgeCount = isPinHigh ? input->risingEdgeCount : input->fallingEdgeCount;
+        if (edgeCount < 255) {
+            ++edgeCount;
+        }
+        if (!isInputQualified(*input)) {
+            return;
+        }
+        // The begin guard and the active callback run in the same call, so the first qualified
+        // falling edge both begins the burst and requests the outputs.
+        if (!isPinHigh && input->beginTimestamp == 0) {
+            input->beginTimestamp = foregroundSnapshotMilliseconds;
+            runInputStateChange(state, role, 1, foregroundSnapshotMilliseconds, 0);
+        }
+        if (role == InputRole::Shot) {
+            runShotActiveCallback(state, foregroundSnapshotMilliseconds, input->beginTimestamp);
+        }
+        return;
+    }
+
+    if (input->lastChangeTimestamp != 0 &&
+        static_cast<uint32_t>(foregroundSnapshotMilliseconds - input->lastChangeTimestamp) >= kInputStableDuration) {
+        const bool wasQualified = isInputQualified(*input);
+        const uint32_t beginTimestamp = input->beginTimestamp;
+        input->risingEdgeCount = 0;
+        input->fallingEdgeCount = 0;
+        input->beginTimestamp = 0;
+        input->lastChangeTimestamp = 0;
+        if (wasQualified) {
+            input->endTimestamp = foregroundSnapshotMilliseconds;
+            runInputStateChange(state, role, 0, foregroundSnapshotMilliseconds, beginTimestamp);
+        }
+    }
+#endif
+}
+
+#if defined(MECOFFEE_RUST_SHOT)
+extern "C" void mecoffee_shot_effect(void* context,uint8_t operation,uint32_t value) {
+    auto* state=static_cast<FirmwareState*>(context);
+    switch(operation) {
+        case 0: state->powerSchedule.lastActivityTimestamp=value;break;
+        case 1: {
+            const uint8_t saved=SREG;cli();
+            setGroupEnabled(&groupForRole(&state->outputChannels,static_cast<OutputGroupRole>(value&255)),(value&256)!=0);
+            SREG=saved;break;
+        }
+        case 2: beginShotFeedForward(state,value);break;
+        case 3: endShotFeedForward(state,value);break;
+        case 4: enqueueDiagnosticCallback(&state->diagnosticQueue,reportShotBegin);break;
+        case 5: enqueueDiagnosticCallback(&state->diagnosticQueue,reportShotEnd);break;
+    }
+}
+extern "C" void mecoffee_input_event(void* context,uint8_t role,uint8_t active,uint32_t now,uint32_t begin) {
+    auto* state=static_cast<FirmwareState*>(context);
+    // Use the supplied timestamp, never read the Rust-borrowed DebouncedInput.
+    if(active==2) runShotActiveCallback(state,now,begin);
+    else runInputStateChange(state,static_cast<InputRole>(role),active,now,begin);
+}
+#endif
+
+}  // namespace meCoffee

+ 38 - 0
lib/meCoffee_controller_logic/src/shot_control.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+constexpr uint8_t kPowerInputLogicalPin = 4;
+constexpr uint8_t kModeInputLogicalPin = 5;
+constexpr uint8_t kShotInputLogicalPin = 8;
+
+constexpr uint8_t kRequiredEdgesPerLevel = 3;
+constexpr uint32_t kInputStableDuration = 51;
+
+enum class InputRole : uint8_t { Power, Mode, Shot };
+
+bool isInputQualified(const DebouncedInput& input);
+
+// Initial level of an input is high (pull-up).
+void initializeDebouncedInput(DebouncedInput* input);
+
+// Samples the pin and runs the input's callbacks (see the engine description in
+// docs/STATUS.md). `foregroundSnapshotMilliseconds` is the time stamp used for
+// every recorded change.
+void pollDebouncedInput(FirmwareState* state, DebouncedInput* input, InputRole role, bool isPinHigh,
+                        uint32_t foregroundSnapshotMilliseconds);
+
+// Shot-limit and preinfusion decision for one callback (exposed for tests).
+struct ShotOutputRequest {
+    bool isPumpRequested;
+    bool isValveRequested;
+    bool isTimedOut;
+};
+
+ShotOutputRequest evaluateShotOutputRequest(const SettingsValues& settings, uint8_t alternateMode, uint32_t elapsedMilliseconds);
+
+}  // namespace meCoffee

+ 60 - 0
lib/meCoffee_controller_logic/src/telemetry_reports.cpp

@@ -0,0 +1,60 @@
+#if !defined(MECOFFEE_RUST_TELEMETRY)
+#include "telemetry_reports.h"
+
+#include "hardware_seam.h"
+#include "program_memory.h"
+#include "serial_output.h"
+
+namespace meCoffee {
+
+uint16_t activeSetpointRaw(const FirmwareState& state) {
+    return static_cast<uint16_t>(state.alternateModeSelection == 0 ? state.settings.brewSetpoint : state.settings.steamSetpoint);
+}
+
+void writeBootTemperatureFrame(const FirmwareState& state) {
+    writeCarriageReturnLineFeed();
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("tmp 0 "));
+    writeUnsignedDecimalText(activeSetpointRaw(state));
+    writeSpace();
+    writeSignedDecimalText(state.sensor.startupAggregate);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" 0 OK"));
+    writeCarriageReturnLineFeed();
+}
+
+void reportTemperatureFrame(FirmwareState& state) {
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("tmp "));
+    writeUnsignedDecimalText(state.sensor.aggregateGeneration);
+    writeSpace();
+    writeUnsignedDecimalText(activeSetpointRaw(state));
+    writeSpace();
+    writeSignedDecimalText(state.sensor.firstChannelAggregate);
+    writeSpace();
+    writeSignedDecimalText(state.sensor.secondChannelAggregate);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" - "));
+    writeUnsignedDecimalText(state.mainsPhase.filteredTicksPerHalfCycle);
+    writeSpace();
+    writeUnsignedDecimalText(state.qualifiedEventCountLastGeneration);
+    writeSpace();
+    writeUnsignedDecimalText(hardware::readOscillatorCalibrationRegister());
+    writeSpace();
+    writeUnsignedDecimalText(state.oscillatorBaselineByte);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+}
+
+void reportHeaterTermsFrame(FirmwareState& state) {
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT("pid "));
+    writeSignedDecimalText(state.heater.proportionalTerm);
+    writeSpace();
+    writeSignedDecimalText(state.heater.integralTerm);
+    writeSpace();
+    writeSignedDecimalText(state.heater.derivativeTerm);
+    writeSpace();
+    writeSignedDecimalText(state.heater.feedForwardTerm);
+    writeProgramMemoryText(MECOFFEE_PROGRAM_TEXT(" OK"));
+    writeCarriageReturnLineFeed();
+}
+
+}  // namespace meCoffee
+
+#endif

+ 19 - 0
lib/meCoffee_controller_logic/src/telemetry_reports.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "firmware_state.h"
+
+namespace meCoffee {
+
+// `\r\ntmp 0 <setpoint> <boiler aggregate> 0 OK\r\n`, printed once at start-up.
+void writeBootTemperatureFrame(const FirmwareState& state);
+
+// `tmp <generation> <setpoint> <boiler> <second sensor> - <phase> <events> <osccal> <baseline> OK`.
+void reportTemperatureFrame(FirmwareState& state);
+
+// `pid <proportional> <integral> <derivative> <feed-forward> OK`.
+void reportHeaterTermsFrame(FirmwareState& state);
+
+// Setpoint of the active mode, as the unsigned 16-bit value the frame prints.
+uint16_t activeSetpointRaw(const FirmwareState& state);
+
+}  // namespace meCoffee

+ 229 - 0
lib/meCoffee_hardware_access/src/arduino_hardware.cpp

@@ -0,0 +1,229 @@
+#include <Arduino.h>
+#include <avr/boot.h>
+#include <avr/eeprom.h>
+#include <avr/pgmspace.h>
+
+#include <firmware_runtime.h>
+#include <hardware_seam.h>
+
+#include "board_profile.h"
+
+static_assert(F_CPU == meCoffee::hardware::board::kClockFrequency, "Clock frequency does not match the board profile");
+static_assert(meCoffee::hardware::board::kAdcPrescaler == 128, "ADC register configuration requires prescaler 128");
+
+// Optional start-up padding that lines this image up with the original's start-up time in
+// differential runs. Zero in the default build.
+#ifndef MECOFFEE_CONSTRUCTION_ALIGNMENT_MICROSECONDS
+#define MECOFFEE_CONSTRUCTION_ALIGNMENT_MICROSECONDS 0
+#endif
+#ifndef MECOFFEE_SETUP_ALIGNMENT_MICROSECONDS
+#define MECOFFEE_SETUP_ALIGNMENT_MICROSECONDS 0
+#endif
+#include <mains_events.h>
+#include <pulse_tick.h>
+
+namespace meCoffee::hardware {
+
+void beginSerialLink(uint32_t baudRate) {
+#if MECOFFEE_SETUP_ALIGNMENT_MICROSECONDS > 0
+    static bool isFirstCall = true;
+    if (isFirstCall) {
+        isFirstCall = false;
+        delayMicroseconds(MECOFFEE_SETUP_ALIGNMENT_MICROSECONDS);
+    }
+#endif
+    Serial.begin(baudRate);
+}
+
+void writeSerialByte(uint8_t byteToTransmit) { Serial.write(byteToTransmit); }
+
+int readSerialByte() { return Serial.read(); }
+
+void waitForSerialTransmitCompletion() { Serial.flush(); }
+
+uint32_t readMillisecondCounter() { return millis(); }
+
+uint32_t readMicrosecondCounter() { return micros(); }
+
+void blockForMilliseconds(uint32_t durationInMilliseconds) { delay(durationInMilliseconds); }
+
+uint8_t readPersistentByte(uint16_t persistentAddress) {
+    return eeprom_read_byte(reinterpret_cast<const uint8_t*>(persistentAddress));
+}
+
+void writePersistentByte(uint16_t persistentAddress, uint8_t byteToStore) {
+    eeprom_write_byte(reinterpret_cast<uint8_t*>(persistentAddress), byteToStore);
+}
+
+uint8_t readProgramMemoryByte(const void* programMemoryAddress) { return pgm_read_byte(programMemoryAddress); }
+
+uint8_t readDeviceSignatureByte(uint8_t signatureByteIndex) {
+    // The signature read must complete within a few cycles of the control write.
+    const uint8_t savedStatusRegister = SREG;
+    cli();
+    const uint8_t signatureByte = boot_signature_byte_get(signatureByteIndex);
+    SREG = savedStatusRegister;
+    return signatureByte;
+}
+
+void configureLogicalPin(uint8_t logicalPin, PinConfiguration configuration) {
+    const uint8_t physicalPin = board::physicalPin(logicalPin);
+    switch (configuration) {
+        case PinConfiguration::Input: pinMode(physicalPin, INPUT); break;
+        case PinConfiguration::InputWithPullUp: pinMode(physicalPin, INPUT_PULLUP); break;
+        case PinConfiguration::Output: pinMode(physicalPin, OUTPUT); break;
+    }
+}
+
+void writeLogicalPin(uint8_t logicalPin, bool isHigh) { digitalWrite(board::physicalPin(logicalPin), isHigh ? HIGH : LOW); }
+
+bool readLogicalPin(uint8_t logicalPin) { return digitalRead(board::physicalPin(logicalPin)) != LOW; }
+
+void selectAnalogChannel(uint8_t channelNumber) { ADMUX = static_cast<uint8_t>(0xc0 | (board::analogChannel(channelNumber) & 0x0f)); }
+
+void startAnalogConversion() { ADCSRA |= _BV(ADSC); }
+
+bool isAnalogConversionRunning() { return (ADCSRA & _BV(ADSC)) != 0; }
+
+uint16_t readAnalogConversionResult() {
+    const uint8_t lowByte = ADCL;
+    const uint8_t highByte = ADCH;
+    return static_cast<uint16_t>(lowByte | (highByte << 8));
+}
+
+void clearPulseOutputPins() {
+#if MECOFFEE_ARDUINO_16MHZ
+    PORTD &= static_cast<uint8_t>(~(_BV(PD3) | _BV(PD4) | _BV(PD5)));
+#else
+    PORTB &= static_cast<uint8_t>(~(_BV(PB1) | _BV(PB2)));
+    PORTD &= static_cast<uint8_t>(~_BV(PD3));
+#endif
+}
+
+void writeRangeIndicatorPin(uint8_t rangeSelector, bool isHigh) {
+#if MECOFFEE_ARDUINO_16MHZ
+    const uint8_t rangeMask = rangeSelector == 0 ? _BV(PC2) : _BV(PC3);
+    if (isHigh) {
+        PORTC |= rangeMask;
+    } else {
+        PORTC &= static_cast<uint8_t>(~rangeMask);
+    }
+#else
+    if (rangeSelector == 0) {
+        if (isHigh) {
+            PORTB |= _BV(PB7);
+        } else {
+            PORTB &= static_cast<uint8_t>(~_BV(PB7));
+        }
+    } else if (isHigh) {
+        PORTD |= _BV(PD7);
+    } else {
+        PORTD &= static_cast<uint8_t>(~_BV(PD7));
+    }
+#endif
+}
+
+void writeGenerationParityPin(bool isHigh) {
+#if MECOFFEE_ARDUINO_16MHZ
+    if (isHigh) {
+        PORTB |= _BV(PB3);
+    } else {
+        PORTB &= static_cast<uint8_t>(~_BV(PB3));
+    }
+#else
+    if (isHigh) {
+        PORTC |= _BV(PC0);
+    } else {
+        PORTC &= static_cast<uint8_t>(~_BV(PC0));
+    }
+#endif
+}
+
+void startPulseTimerAndMainsInterrupt() {
+    // Mains edge: logical pin 2 (INT0), no pull-up, both edges.
+    pinMode(2, INPUT);
+    EICRA = static_cast<uint8_t>((EICRA & ~(_BV(ISC01) | _BV(ISC00))) | _BV(ISC00));
+    EIMSK |= _BV(INT0);
+
+    // Output pins that follow the sensor range flags and generation parity.
+#if MECOFFEE_ARDUINO_16MHZ
+    DDRC |= _BV(PC2) | _BV(PC3);
+    PORTC &= static_cast<uint8_t>(~(_BV(PC2) | _BV(PC3)));
+    DDRB |= _BV(PB3);
+    PORTB |= _BV(PB3);
+#else
+    DDRB |= _BV(PB7);
+    PORTB &= static_cast<uint8_t>(~_BV(PB7));
+    DDRC |= _BV(PC0);
+    PORTC |= _BV(PC0);
+#endif
+
+    // Pulse timer: Timer 1, CTC, no prescaling, compare interrupt every 16.125 microseconds.
+    TCCR1A = 0;
+    TCCR1B = 0;
+    TCNT1 = 0;
+    OCR1A = board::kPulseTimerCompare;
+    TCCR1B = _BV(WGM12) | _BV(CS10);
+    TIMSK1 = _BV(OCIE1A);
+
+    // Analog converter: internal reference, first temperature channel, prescaler 128.
+    selectAnalogChannel(3);
+    ADCSRA = _BV(ADEN) | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0);
+}
+
+void disableInterrupts() { cli(); }
+
+void enableInterrupts() { sei(); }
+
+uint8_t readOscillatorCalibrationRegister() { return OSCCAL; }
+
+void writeOscillatorCalibrationRegister(uint8_t calibrationValue) {
+    if (!board::kUsesExternalClock) {
+        OSCCAL = calibrationValue;
+    }
+}
+
+}  // namespace meCoffee::hardware
+
+ISR(TIMER1_COMPA_vect) {
+    const meCoffee::PulseTickResult tickResult = meCoffee::advancePulseTick(&meCoffee::firmwareStateStorage);
+    if ((tickResult.toggleMask & 0x01) != 0) {
+        PIND = _BV(PD3);
+    }
+    if ((tickResult.toggleMask & 0x02) != 0) {
+#if MECOFFEE_ARDUINO_16MHZ
+        PIND = _BV(PD4);
+#else
+        PINB = _BV(PB2);
+#endif
+    }
+    if ((tickResult.toggleMask & 0x04) != 0) {
+#if MECOFFEE_ARDUINO_16MHZ
+        PIND = _BV(PD5);
+#else
+        PINB = _BV(PB1);
+#endif
+    }
+    if (tickResult.shouldStartConversion) {
+        ADCSRA |= _BV(ADSC);
+    }
+}
+
+ISR(INT0_vect) { meCoffee::processMainsEdge(&meCoffee::firmwareStateStorage); }
+
+namespace {
+
+// Mirrors the original's static constructors: settings are restored before the
+// Arduino runtime starts its millisecond timer.
+struct ConstructionTimeInitializer {
+    ConstructionTimeInitializer() {
+        meCoffee::initializeAtConstruction();
+#if MECOFFEE_CONSTRUCTION_ALIGNMENT_MICROSECONDS > 0
+        delayMicroseconds(MECOFFEE_CONSTRUCTION_ALIGNMENT_MICROSECONDS);
+#endif
+    }
+};
+
+const ConstructionTimeInitializer constructionTimeInitializer;
+
+}  // namespace

+ 47 - 0
lib/meCoffee_hardware_access/src/board_profile.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <stdint.h>
+
+#ifndef MECOFFEE_ARDUINO_16MHZ
+#define MECOFFEE_ARDUINO_16MHZ 0
+#endif
+
+namespace meCoffee::hardware::board {
+
+constexpr bool kUsesExternalClock = MECOFFEE_ARDUINO_16MHZ != 0;
+constexpr uint32_t kClockFrequency = kUsesExternalClock ? 16000000UL : 8000000UL;
+constexpr uint16_t kPulseTimerCompare = static_cast<uint16_t>(129UL * (kClockFrequency / 8000000UL) - 1);
+constexpr uint8_t kAdcPrescaler = 128;
+constexpr uint8_t kGenerationParityPin = kUsesExternalClock ? 11 : 14;
+
+inline uint8_t physicalPin(uint8_t logicalPin) {
+    if (!kUsesExternalClock) {
+        return logicalPin;
+    }
+    switch (logicalPin) {
+        case 3: return 3;
+        case 10: return 4;
+        case 9: return 5;
+        case 4: return 6;
+        case 5: return 7;
+        case 8: return 8;
+        case 15: return 9;
+        case 19: return 10;
+        case 16: return 12;
+        default: return logicalPin;
+    }
+}
+
+inline uint8_t analogChannel(uint8_t logicalChannel) {
+    if (kUsesExternalClock) {
+        if (logicalChannel == 3) {
+            return 0;
+        }
+        if (logicalChannel == 4) {
+            return 1;
+        }
+    }
+    return logicalChannel;
+}
+
+}  // namespace meCoffee::hardware::board

+ 66 - 0
lib/meCoffee_hardware_access/src/uno_ble_bootloader.cpp

@@ -0,0 +1,66 @@
+#if defined(MECOFFEE_UNO_BLE_TEST)
+
+#include <Arduino.h>
+#include <avr/eeprom.h>
+#include <avr/wdt.h>
+#include <hardware_seam.h>
+
+#if !defined(__AVR_ATmega328P__) || F_CPU != 16000000UL || !MECOFFEE_ARDUINO_16MHZ
+#error "The BLE bench handoff requires an ATmega328P with the 16 MHz Arduino pin map"
+#endif
+
+namespace meCoffee::hardware {
+
+[[noreturn]] void enterUnoBleTestBootloader() {
+    // Stop pulse generation before draining the acknowledgement. Foreground
+    // control never resumes. Keep radio power/reset pins unchanged so BLE stays up.
+    cli();
+    TIMSK1 = 0;
+    EIMSK = 0;
+    clearPulseOutputPins();
+    sei();
+    Serial.flush();
+    eeprom_busy_wait();
+    cli();
+
+    TIMSK0 = TIMSK1 = TIMSK2 = 0;
+    PCICR = 0;
+    TCCR0A = TCCR0B = TCCR1A = TCCR1B = TCCR2A = TCCR2B = 0;
+    ADCSRA = 0;
+    ACSR = _BV(ACD);
+    TWCR = 0;
+    SPCR = 0;
+    clearPulseOutputPins();
+
+    // Retain the running firmware's exact UART divisor/U2X setting. Arduino's
+    // 16 MHz core special-cases 57600, so recomputing it here changes the wire
+    // rate. Disabling RX clears its buffer; wait for ready before sending STK500.
+    UCSR0B = 0;
+    UCSR0A = (UCSR0A & _BV(U2X0)) | _BV(TXC0);
+    UCSR0C = _BV(UCSZ01) | _BV(UCSZ00);
+    UCSR0B = _BV(RXEN0) | _BV(TXEN0);
+    MCUSR = 0;
+    wdt_enable(WDTO_8S);
+
+    // Exact verified Optiboot 4.4 SHA-256:
+    // e36d971b54b3336178813bf16cddf2658866367874587f7fc6c560fb629fbc74
+    // 0x7e4c is the register initialization before its STK500 loop. Bypass the
+    // external-reset check, 115200 UART setup and LED delay, supplying their
+    // required state here. This is NOT a portable bootloader API. Stock lock
+    // bits prevent application-side fingerprint reads; the USB installer checks.
+    // No code can return after resetting SP; do not add C++ after this jump.
+    asm volatile(
+        "clr r1\n\t"
+        "ldi r24, hi8(%0)\n\t"
+        "out %1, r24\n\t"
+        "ldi r24, lo8(%0)\n\t"
+        "out %2, r24\n\t"
+        "jmp 0x7e4c\n\t"
+        : : "n" (RAMEND), "I" (_SFR_IO_ADDR(SPH)), "I" (_SFR_IO_ADDR(SPL))
+        : "r24", "memory"
+    );
+    __builtin_unreachable();
+}
+
+}  // namespace meCoffee::hardware
+#endif

+ 46 - 0
lib/meCoffee_simulated_hardware/src/firmware_test_support.cpp

@@ -0,0 +1,46 @@
+#include "firmware_test_support.h"
+
+#include <string.h>
+
+#include <diagnostic_queue.h>
+#include <heater_controller.h>
+#include <power_schedule.h>
+#include <shot_control.h>
+
+#include "simulated_hardware.h"
+
+namespace meCoffee::simulated {
+
+void prepareFreshFirmwareState(FirmwareState* state) {
+    memset(state, 0, sizeof *state);
+    assignDefaultSettings(&state->settings);
+    initializeOutputChannels(&state->outputChannels);
+    routeLeafToRoleCharacter(&state->outputChannels, 0, 'p');
+    routeLeafToRoleCharacter(&state->outputChannels, 1, 'b');
+    routeLeafToRoleCharacter(&state->outputChannels, 2, 'v');
+    state->eventClock.fractionPeriod = 50;
+    initializeHeaterController(state, 0);
+    initializeDebouncedInput(&state->powerInput);
+    initializeDebouncedInput(&state->shotInput);
+    initializeDebouncedInput(&state->modeInput);
+    initializePowerSchedule(state);
+    state->sensor.currentChannel = 3;
+}
+
+const char* takeTransmittedText() {
+    SimulatedHardwareState& hardwareStateReference = hardwareState();
+    static char text[kTransmitCaptureSize + 1];
+    memcpy(text, hardwareStateReference.transmittedBytes, hardwareStateReference.transmittedByteCount);
+    text[hardwareStateReference.transmittedByteCount] = '\0';
+    hardwareStateReference.transmittedByteCount = 0;
+    return text;
+}
+
+void queueReceivedText(const char* text) {
+    SimulatedHardwareState& hardwareStateReference = hardwareState();
+    const size_t length = strlen(text);
+    memcpy(hardwareStateReference.receiveBytes + hardwareStateReference.receiveByteCount, text, length);
+    hardwareStateReference.receiveByteCount = static_cast<uint16_t>(hardwareStateReference.receiveByteCount + length);
+}
+
+}  // namespace meCoffee::simulated

+ 17 - 0
lib/meCoffee_simulated_hardware/src/firmware_test_support.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <firmware_state.h>
+
+namespace meCoffee::simulated {
+
+// Brings a FirmwareState to the state right after start-up with default settings
+// and the default output routing, without touching the persistent store.
+void prepareFreshFirmwareState(FirmwareState* state);
+
+// Text transmitted since the last call to `takeTransmittedText`.
+const char* takeTransmittedText();
+
+// Feeds text to the receive side of the simulated serial link.
+void queueReceivedText(const char* text);
+
+}  // namespace meCoffee::simulated

+ 142 - 0
lib/meCoffee_simulated_hardware/src/simulated_hardware.cpp

@@ -0,0 +1,142 @@
+#include <hardware_seam.h>
+
+#include "simulated_hardware.h"
+
+namespace meCoffee::simulated {
+
+SimulatedHardwareState& hardwareState() {
+    static SimulatedHardwareState state;
+    return state;
+}
+
+void resetSimulatedHardware() {
+    SimulatedHardwareState& state = hardwareState();
+    state = SimulatedHardwareState{};
+    for (uint16_t address = 0; address < kPersistentStoreSize; ++address) {
+        state.persistentStore[address] = 0xff;
+    }
+}
+
+}  // namespace meCoffee::simulated
+
+namespace meCoffee::simulated {
+
+void queueReceivedTextAt(const char* text, uint32_t availableAtMillisecond) {
+    SimulatedHardwareState& state = hardwareState();
+    for (; *text != '\0'; ++text) {
+        state.receiveBytes[state.receiveByteCount] = static_cast<uint8_t>(*text);
+        state.receiveByteAvailableAtMillisecond[state.receiveByteCount] = availableAtMillisecond;
+        ++state.receiveByteCount;
+    }
+}
+
+}  // namespace meCoffee::simulated
+
+namespace meCoffee::hardware {
+
+void beginSerialLink(uint32_t baudRate) { simulated::hardwareState().serialBaudRate = baudRate; }
+
+void writeSerialByte(uint8_t byteToTransmit) {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    if (state.transmittedByteCount < simulated::kTransmitCaptureSize) {
+        state.transmittedBytes[state.transmittedByteCount++] = byteToTransmit;
+    }
+}
+
+int readSerialByte() {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    if (state.receiveReadPosition >= state.receiveByteCount ||
+        state.receiveByteAvailableAtMillisecond[state.receiveReadPosition] > state.millisecondCounter) {
+        return -1;
+    }
+    return state.receiveBytes[state.receiveReadPosition++];
+}
+
+void waitForSerialTransmitCompletion() {}
+
+uint32_t readMillisecondCounter() {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    const uint32_t reading = state.millisecondCounter;
+    state.millisecondCounter += state.millisecondsAddedPerClockRead;
+    return reading;
+}
+
+uint32_t readMicrosecondCounter() { return simulated::hardwareState().millisecondCounter * 1000UL; }
+
+void blockForMilliseconds(uint32_t durationInMilliseconds) {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    if (state.blockObserver != nullptr) {
+        state.blockObserver(durationInMilliseconds);
+    }
+    state.millisecondCounter += durationInMilliseconds;
+}
+
+uint8_t readPersistentByte(uint16_t persistentAddress) {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    ++state.persistentReadCount;
+    return state.persistentStore[persistentAddress % simulated::kPersistentStoreSize];
+}
+
+void writePersistentByte(uint16_t persistentAddress, uint8_t byteToStore) {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    if (state.persistentWriteCount < simulated::kPersistentWriteLogCapacity) {
+        state.persistentWriteLog[state.persistentWriteCount] = {persistentAddress, byteToStore};
+    }
+    ++state.persistentWriteCount;
+    state.persistentStore[persistentAddress % simulated::kPersistentStoreSize] = byteToStore;
+}
+
+uint8_t readProgramMemoryByte(const void* programMemoryAddress) {
+    return *static_cast<const uint8_t*>(programMemoryAddress);
+}
+
+uint8_t readDeviceSignatureByte(uint8_t signatureByteIndex) {
+    static const uint8_t kSignatureBytes[5] = {0x1e, 0x00, 0x95, 0x00, 0x0f};
+    return kSignatureBytes[signatureByteIndex % 5];
+}
+
+void configureLogicalPin(uint8_t logicalPin, PinConfiguration configuration) {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    state.pinConfigurations[logicalPin % simulated::kSimulatedPinCount] = static_cast<uint8_t>(configuration);
+}
+
+void writeLogicalPin(uint8_t logicalPin, bool isHigh) {
+    simulated::hardwareState().pinLevels[logicalPin % simulated::kSimulatedPinCount] = isHigh;
+}
+
+bool readLogicalPin(uint8_t logicalPin) {
+    return simulated::hardwareState().pinLevels[logicalPin % simulated::kSimulatedPinCount];
+}
+
+void selectAnalogChannel(uint8_t channelNumber) { simulated::hardwareState().analogChannel = channelNumber; }
+
+void startAnalogConversion() { ++simulated::hardwareState().analogConversionStartCount; }
+
+bool isAnalogConversionRunning() { return false; }
+
+uint16_t readAnalogConversionResult() {
+    simulated::SimulatedHardwareState& state = simulated::hardwareState();
+    return state.analogResults[state.analogChannel & 1];
+}
+
+void clearPulseOutputPins() { ++simulated::hardwareState().pulseClearCount; }
+
+void writeRangeIndicatorPin(uint8_t rangeSelector, bool isHigh) {
+    simulated::hardwareState().rangeIndicatorLevels[rangeSelector & 1] = isHigh;
+}
+
+void writeGenerationParityPin(bool isHigh) { simulated::hardwareState().generationParityLevel = isHigh; }
+
+void startPulseTimerAndMainsInterrupt() {}
+
+void disableInterrupts() {}
+
+void enableInterrupts() {}
+
+uint8_t readOscillatorCalibrationRegister() { return simulated::hardwareState().oscillatorCalibration; }
+
+void writeOscillatorCalibrationRegister(uint8_t calibrationValue) {
+    simulated::hardwareState().oscillatorCalibration = calibrationValue;
+}
+
+}  // namespace meCoffee::hardware

+ 57 - 0
lib/meCoffee_simulated_hardware/src/simulated_hardware.h

@@ -0,0 +1,57 @@
+#pragma once
+
+#include <stdint.h>
+
+namespace meCoffee::simulated {
+
+constexpr uint16_t kPersistentStoreSize = 1024;
+constexpr uint16_t kTransmitCaptureSize = 2048;
+constexpr uint16_t kReceiveCaptureSize = 512;
+constexpr uint8_t kSimulatedPinCount = 20;
+constexpr uint16_t kPersistentWriteLogCapacity = 4096;
+
+struct PersistentWriteRecord {
+    uint16_t address;
+    uint8_t value;
+};
+
+// Called with the requested duration before a blocking delay advances the simulated clock.
+using BlockObserver = void (*)(uint32_t durationInMilliseconds);
+
+// In-memory stand-in for the processor used by host tests.
+struct SimulatedHardwareState {
+    uint8_t persistentStore[kPersistentStoreSize];
+    uint32_t persistentReadCount;
+    uint32_t persistentWriteCount;
+    uint8_t transmittedBytes[kTransmitCaptureSize];
+    uint16_t transmittedByteCount;
+    uint8_t receiveBytes[kReceiveCaptureSize];
+    uint16_t receiveByteCount;
+    uint16_t receiveReadPosition;
+    uint32_t millisecondCounter;
+    uint32_t serialBaudRate;
+    uint8_t pinConfigurations[kSimulatedPinCount];
+    bool pinLevels[kSimulatedPinCount];
+    PersistentWriteRecord persistentWriteLog[kPersistentWriteLogCapacity];
+    uint32_t millisecondsAddedPerClockRead;
+    uint32_t receiveByteAvailableAtMillisecond[kReceiveCaptureSize];
+    BlockObserver blockObserver;
+    uint8_t oscillatorCalibration;
+    uint8_t analogChannel;
+    uint16_t analogResults[2];
+    uint32_t analogConversionStartCount;
+    uint32_t pulseClearCount;
+    bool rangeIndicatorLevels[2];
+    bool generationParityLevel;
+};
+
+SimulatedHardwareState& hardwareState();
+
+// Queues bytes that only become readable once the simulated millisecond counter reaches `availableAtMillisecond`.
+void queueReceivedTextAt(const char* text, uint32_t availableAtMillisecond);
+
+// Restores the power-on state: erased (0xff) persistent store, empty serial
+// buffers, counters at zero.
+void resetSimulatedHardware();
+
+}  // namespace meCoffee::simulated

+ 91 - 0
platformio.ini

@@ -0,0 +1,91 @@
+; meCoffee firmware (PlatformIO, Arduino core + plain C++ logic library)
+; See README.md and docs/AGENT_HANDOFF.md.
+
+[platformio]
+default_envs = 328p8m
+
+[common]
+build_flags =
+    -DCOMPAT_V10=1
+    -Wall
+    -Wextra
+extra_scripts = pre:tools/apply_cplusplus_flags.py
+
+[env:328p8m]
+platform = atmelavr@5.3.0
+board = 328p8m
+framework = arduino
+; The registry toolchain (x86_64 avr-gcc 7.3) does not run on Apple Silicon without
+; Rosetta. tools/toolchain-native wraps the Homebrew avr-gcc 9.5 (arm64).
+; On Intel/Rosetta hosts use: toolchain-atmelavr@1.70300.191015
+platform_packages =
+    framework-arduino-avr@5.4.0
+    toolchain-atmelavr@symlink://tools/toolchain-native
+build_flags = ${common.build_flags}
+extra_scripts = ${common.extra_scripts}
+lib_deps =
+    meCoffee_controller_logic
+    meCoffee_hardware_access
+lib_ignore = meCoffee_simulated_hardware
+lib_ldf_mode = chain
+upload_protocol = arduino
+upload_speed = 57600
+
+[env:uno]
+extends = env:328p8m
+board = uno
+board_build.f_cpu = 16000000L
+build_flags =
+    ${common.build_flags}
+    -DMECOFFEE_ARDUINO_16MHZ=1
+upload_speed = 115200
+
+[env:pro_mini]
+extends = env:uno
+board = pro16MHzatmega328
+upload_speed = 57600
+
+[env:nano]
+extends = env:uno
+board = nanoatmega328new
+upload_speed = 115200
+
+; Bench-only BLE upload experiment, pinned to the verified stock Uno Optiboot 4.4.
+; See docs/UNO_BLE_FLASH_TEST.md. USB installer checks the bootloader before writing.
+[env:uno_ble_test]
+extends = env:uno
+build_flags =
+    ${env:uno.build_flags}
+    -DMECOFFEE_UNO_BLE_TEST=1
+upload_protocol = custom
+upload_command = "$PYTHONEXE" "$PROJECT_DIR/tools/ble_test/upload_usb.py" --port "$UPLOAD_PORT" "$SOURCE"
+
+; Same firmware with start-up padded to the original's start-up time (differential runs only).
+[env:328p8m_aligned]
+extends = env:328p8m
+build_flags =
+    ${common.build_flags}
+    -DMECOFFEE_CONSTRUCTION_ALIGNMENT_MICROSECONDS=5900
+    -DMECOFFEE_SETUP_ALIGNMENT_MICROSECONDS=1100
+
+[env:float_check]
+extends = env:328p8m
+build_src_filter = -<*> +<../tools/avr_checks/float_check.cpp>
+
+[env:native]
+platform = native
+build_flags =
+    ${common.build_flags}
+    -DNATIVE_TEST=1
+extra_scripts = ${common.extra_scripts}
+lib_ignore = meCoffee_hardware_access
+lib_deps =
+    meCoffee_controller_logic
+    meCoffee_simulated_hardware
+test_framework = unity
+
+[env:native_arduino16m]
+extends = env:native
+build_flags =
+    ${env:native.build_flags}
+    -DMECOFFEE_ARDUINO_16MHZ=1

+ 7 - 0
src/main.cpp

@@ -0,0 +1,7 @@
+#include <Arduino.h>
+
+#include <firmware_runtime.h>
+
+void setup() { meCoffee::startFirmware(); }
+
+void loop() { meCoffee::runForegroundIteration(); }

+ 6 - 0
tools/apply_cplusplus_flags.py

@@ -0,0 +1,6 @@
+# Adds the C++-only compiler flags to the C++ flag list so C sources of the Arduino
+# core do not warn about options that do not apply to C.
+Import("env")
+
+env.Replace(CXXFLAGS=[flag for flag in env.get("CXXFLAGS", []) if flag != "-std=gnu++11"])
+env.Append(CXXFLAGS=["-std=gnu++17", "-fno-exceptions", "-fno-rtti", "-fno-threadsafe-statics"])

BIN
tools/orig/flash.bin


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1659 - 0
tools/orig/meCoffee-V10.hex


+ 1 - 0
tools/toolchain-native/bin/avr-addr2line

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-addr2line

+ 1 - 0
tools/toolchain-native/bin/avr-ar

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-ar

+ 1 - 0
tools/toolchain-native/bin/avr-as

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-as

+ 1 - 0
tools/toolchain-native/bin/avr-c++

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-c++

+ 1 - 0
tools/toolchain-native/bin/avr-c++filt

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-c++filt

+ 1 - 0
tools/toolchain-native/bin/avr-cpp

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-cpp

+ 1 - 0
tools/toolchain-native/bin/avr-elfedit

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-elfedit

+ 1 - 0
tools/toolchain-native/bin/avr-g++

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-g++

+ 1 - 0
tools/toolchain-native/bin/avr-gcc

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcc

+ 1 - 0
tools/toolchain-native/bin/avr-gcc-9.5.0

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcc-9.5.0

+ 1 - 0
tools/toolchain-native/bin/avr-gcc-ar

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcc-ar

+ 1 - 0
tools/toolchain-native/bin/avr-gcc-nm

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcc-nm

+ 1 - 0
tools/toolchain-native/bin/avr-gcc-ranlib

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcc-ranlib

+ 1 - 0
tools/toolchain-native/bin/avr-gcov

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcov

+ 1 - 0
tools/toolchain-native/bin/avr-gcov-dump

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcov-dump

+ 1 - 0
tools/toolchain-native/bin/avr-gcov-tool

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gcov-tool

+ 1 - 0
tools/toolchain-native/bin/avr-gprof

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-gprof

+ 1 - 0
tools/toolchain-native/bin/avr-ld

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-ld

+ 1 - 0
tools/toolchain-native/bin/avr-ld.bfd

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-ld.bfd

+ 1 - 0
tools/toolchain-native/bin/avr-man

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-man

+ 1 - 0
tools/toolchain-native/bin/avr-nm

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-nm

+ 1 - 0
tools/toolchain-native/bin/avr-objcopy

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-objcopy

+ 1 - 0
tools/toolchain-native/bin/avr-objdump

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-objdump

+ 1 - 0
tools/toolchain-native/bin/avr-ranlib

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-ranlib

+ 1 - 0
tools/toolchain-native/bin/avr-readelf

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-readelf

+ 1 - 0
tools/toolchain-native/bin/avr-size

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-size

+ 1 - 0
tools/toolchain-native/bin/avr-strings

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-strings

+ 1 - 0
tools/toolchain-native/bin/avr-strip

@@ -0,0 +1 @@
+/opt/homebrew/bin/avr-strip

+ 1 - 0
tools/toolchain-native/package.json

@@ -0,0 +1 @@
+{"name": "toolchain-atmelavr", "version": "1.70300.250101", "description": "Native arm64 wrapper around the Homebrew avr-gcc 9.5 toolchain", "system": ["darwin_arm64"]}