Refactor a ~5k-line Mount god-object firmware toward clean architecture for embedded:
a pure Domain Core (no Arduino, fully unit-testable) sitting behind Port interfaces,
with Adapters wrapping hardware (AccelStepper, TMC2209, EEPROM, displays, Wi-Fi, clock).
Approach: hybrid — extract pure logic in place first to build a regression-prevention test net (Unity + FakeIt via ArduinoFake on the existing native PIO env), then strangler-fig the hardware-coupled pieces (drivers, slewing loop, command executor) behind ports. Compile-time #ifdef axes/drivers migrate to runtime polymorphism so unsupported combinations no longer change the call graph. Goal endpoint: src/core/ is buildable & 100% unit-tested on host; src/adapters/ contains all Arduino/library coupling; src/app/ wires them up per board.
Five layers, dependencies point inward only. The HAL sits between domain ports and the actual hardware libraries, so swapping AccelStepper/TMCStepper/EEPROM/SSD1306/Wi-Fi never touches core/ or ports/.
+----------------------------------------------------------------+
| app/ per-board composition root (main.cpp + setup |
| | wiring, replaces the legacy .ino entry point) |
| v |
| adapters/ bind domain ports to HAL (and to non-HAL libs |
| | such as TinyGPS data structs); thin glue. |
| v |
| hal/ Hardware Abstraction Layer — pure C++ interfaces |
| | for physical components + per-platform backends: |
| | IGpioPin, ISerialPort, ISpiBus, II2cBus, |
| | IEeprom, IStepperMotor, ITmcDriver, IOledPanel,|
| | ICharLcd, IButtonMatrix, ITimerService, |
| | ISystemClock, IWifiStack. |
| | Backends: hal/arduino/, hal/esp32/, hal/avr/. |
| | Host-side fakes for unit tests live under |
| | unit_tests/test_common/ (test code, not src/). |
| v |
| ports/ domain-level interfaces consumed by core: |
| | IClock, ILogger, IPersistentStore, IStepperAxis, |
| | IMotorDriver, IDisplay, IInfoDisplay, ITransport,|
| | IHomingSensor, IEndSwitch, IGps. |
| v |
| core/ pure domain logic, no Arduino, host-testable: |
| CoordinateMath, MountState, SlewController, |
| GuidingController, TrackingController, |
| ParkingController, HomingController, |
| FocusController, SiderealClock, MeadeParser, |
| MountConfig, EventBus. |
+----------------------------------------------------------------+
HAL vs Ports — why both:
hal/describes what the hardware can do (pin toggles, UART bytes, timer ticks). One backend per platform; one in-memory backend for tests.ports/describes what the domain needs (axis position, persistent value, "now"). Adapters compose one or more HAL services to satisfy a port — e.g.AccelStepperAxisadapter implementsIStepperAxisusingIStepperMotor+ITimerServicefrom HAL.
Cross-cutting:
- Configuration becomes a runtime
MountConfigstruct populated at composition time fromConfiguration.hppconstants (single translation unit reads the macros).#ifdefno longer leaks intocore/orports/; HAL backend selection is the only place feature flags survive. - Time is a
IClockport backed byhal::ISystemClock;core/never callsmillis()directly. - Logging is an
ILoggerport backed byhal::ISerialPort;core/never includesSerial. - Optional axes (
AZ,ALT,Focus) becomestd::optional<AxisController>or null-object pattern — no#ifdefbranches in controllers.
Mount.cpp ends up as a thin facade (≤ 500 LOC) over core/ controllers, retained for Meade protocol back-compat; gradually deprecated.
The Meade LX200 command parser has been fully extracted into src/core/meade/ as a pure, host-testable, allocation-light parser with no side effects. The split is:
| Layer | Location | Status |
|---|---|---|
| Parser (pure) | src/core/meade/MeadeParser.* + 11 family-specific .cpp files |
✅ Done — 100% covered by 13 test files in unit_tests/test_meade/ |
| Protocol spec | src/core/meade/MeadeProtocol.hpp |
✅ Done — comprehensive protocol documentation |
| Handler interfaces | src/core/meade/MeadeParser.hpp (12 IMeade*Handlers interfaces + aggregate IMeadeHandlers) |
✅ Done — clean typed contracts |
| Executor/Adapter | src/MeadeCommandProcessor.hpp/cpp |
✅ Done — implements IMeadeHandlers, delegates to Mount/LcdMenu |
The parser directory (src/core/meade/) contains 16 files covering all 12 command families (Get, Set, Quit, Distance, Init, SyncControl, Home, SlewRate, GPS, Focus, Movement, Extra). Each family has:
- A typed handler interface (e.g.
IMeadeGetHandlers,IMeadeMovementHandlers) - A
handleMeade*entry point that parses suffixes, invokes handler callbacks, and serializesMeadeResponse - Value types for coordinates (e.g.
RaCoordinate,DecCoordinate)
The MeadeCommandProcessor adapter bridges the parser to the legacy Mount singleton, implementing all ~90 handler overrides. It is the only file in src/ root that references the core parser.
13 test files in unit_tests/test_meade/ provide comprehensive wire-byte coverage for every parser family using fake handler stubs. Tests verify:
- Exact wire-byte formatting (zero-padding, sign rules, terminators)
- Suffix classification and handler dispatch routing
- Edge cases (malformed input, overflow, unknown sub-commands)
- Parser-level validation (the
parseMeadeCommandclassifier)
The parser is pure and tested. The executor (MeadeCommandProcessor) is an adapter that still couples to Mount* and LcdMenu* directly. The Mount god-object still contains all domain logic (slewing, tracking, guiding, parking, homing, focus, coordinate math). No HAL, ports, or controllers exist yet beyond the meade parser.
Foundation for everything else; must land first. Already implemented — see audit below.
Add FFF as header-only dep— Superseded. FakeIt (bundled with ArduinoFake) replaces FFF. No separate FFF needed.Add— Superseded. Only thenative_corePIO envnativeenv is used. The existingnativeenv already has strict warnings (-Wall -Wextra -Werror -Wpedantic -Wshadow).- ✅ gcovr-based coverage reporting in
nativeenv — Done.scripts/test-coverage.py+--coverageflag inbuild_src_flags.pio run -e native -t coverageproduces HTML + markdown reports. Current: 88.3% lines, 97.0% functions, 76.5% branches. - ✅ CI workflow — Done.
.github/workflows/ci.ymlrunspio run -e native -t coverage(which internally executespio test -e native -vvv), publishes coverage markdown to step summary. Threshold gating deferred to a later phase. - ✅ ArduinoFake as
test_lib_deps— Done. Configured inplatformio.ini[env:native]asArduinoFake@^0.4.0. Provides stubbing/verification via FakeIt-based API for Arduino API mocking (millis,String,pinMode,digitalWrite, fakeEEPROM, fakeSerial, fakeWire, fakeSPI). - ✅ Folder structure — Done.
src/ports/,src/hal/,src/adapters/,src/app/all exist with descriptive README files.
Verify: pio test -e native -v → 170 tests, 0 failures; coverage report generates; all 5 board matrix builds green via matrix_build.py.
Changes from original plan (per feedback): FFF replaced by FakeIt (in ArduinoFake); native_core env eliminated (use native only); coverage threshold gating deferred to a later phase.
All pure-logic files identified by the audit get exhaustive tests before being moved.
Steps (parallel after Phase 0):
- Add Unity tests for
DayTime,Declination,Latitude,Longitude(arithmetic, parse/format round-trips, sign edges, overflow). - Expand
test_sidereal.hinto fullSiderealcoverage (LST/HA from date/time across edge dates, leap years, DST-irrelevant UTC). - Add tests for
MappedDictboundary behaviors not yet covered. - Add golden-master tests for the largest pure-ish methods currently in
Mount.cppby exercising them as-is through a thin test driver:Mount::calculateRAandDECSteppers(parametrize over hemisphere, meridian-flip, latitude, target).Mount::syncPositionmath.Mount::getLocalDatecalendar increment (leap years, year/month wrap).Mount::DECString/Mount::RAStringformatting. These tests link against a stripped-downMountcompiled with ArduinoFake — they fail the moment behavior shifts during extraction.
Verify: All new tests green; coverage report shows non-trivial line coverage on the listed methods; CI threshold ratcheted up.
Move characterized logic into core/ with no behavior change. Tests from Phase 1 prevent regressions.
- Create
core/CoordinateMathfromMount::calculateRAandDECSteppers+ helpers; replace original with a thin delegate. Inputs/outputs as plain structs (MountGeometry,EquatorialTarget,StepperTarget). - Create
core/SiderealClockwrappingSidereal::statics behind an instance API that takes anIClock. - Create
core/CalendarMathfromMount::getLocalDate. - Create
core/CoordinateFormatterfrom RA/DEC string formatters. - Create
core/MountGeometryvalue type holding steps-per-degree, calibration angles, hemisphere, backlash — replaces scattered Mount fields used by math. Create✅ DONE — Meade parser is already incore/MeadeParserby splittingMeadeCommandProcessor: pure tokenize/dispatch lookup tables incore/; execution stays in adapter for now.core/meade/with 12 family-specific handler interfaces, 13 comprehensive test files, andMeadeCommandProcessoras the adapter implementingIMeadeHandlers.
Verify: Phase 1 tests still green unchanged; firmware binary for each board builds identically (size diff ≈ 0); new core/ files all covered by host tests.
Define the HAL surface, define the domain ports, and route current call sites through them. Keep current behavior bit-for-bit.
- Define HAL interfaces in
src/hal/:IGpioPin,ISerialPort,ISpiBus,II2cBus,IEeprom,IStepperMotor(step/dir pulses, microstep config),ITmcDriver(UART register IO),IOledPanel,ICharLcd,IButtonMatrix,ITimerService(periodic/one-shot callbacks, used by interrupt stepper),ISystemClock(millis/micros),IWifiStack.
- Implement HAL backends:
hal/arduino/— generic Arduino implementation (ArduinoGpioPin,ArduinoSerialPort,ArduinoEeprom,ArduinoSystemClock, …).hal/avr/— AVR-specific bits (Timer1/Timer3 interrupt service, fast pin IO).hal/esp32/— ESP32-specific (hardware timers, Wi-Fi stack glue).unit_tests/test_common/hal_fakes/— pure C++ test fakes (in-memory EEPROM, virtual GPIO, controllable clock, fake serial). Lives in test code, not insrc/. Complements ArduinoFake (ArduinoFake handles the Arduino API layer; hal_fakes handles custom HAL interfaces).
- Define domain ports in
src/ports/:IClock,ILogger,IPersistentStore,IStepperAxis(position, target, speed, accel, run, stop, isRunning,Snapshot()for ISR safety),IMotorDriver(enable/disable, microsteps, current; null implementation for non-TMC),IHomingSensor,IEndSwitch,IDisplay,IInfoDisplay,ITransport,IGps.
- Provide adapters in
src/adapters/that bind ports to HAL:ArduinoClock(portIClock← halISystemClock),SerialLogger,EepromPersistentStore,AccelStepperAxis,InterruptAccelStepperAxis(usehal::ITimerService+hal::IStepperMotor),Tmc2209Driver(UART + standalone variants overhal::ISerialPort),NullMotorDriver,HallHomingSensor,GpioEndSwitch(overhal::IGpioPin),LcdMenuDisplay,Ssd1306InfoDisplay(overhal::IOledPanel/hal::ICharLcd),SerialTransport,WifiTransport(overhal::ISerialPort/hal::IWifiStack),TinyGpsAdapter.
- Refactor
Mountto hold port pointers (IStepperAxis* _ra; IClock* _clock; ...) injected at construction instead of owning concrete types. Composition happens inapp/(currentlyb_setup.hpp). - Replace direct
millis(),digitalWrite(),EEPROMStore::calls insideMountwith port calls; replaceLOG()macro with_logger->log(...).
Verify: All Phase 1/2 tests still green; new contract tests for each port using HAL fakes from unit_tests/test_common/hal_fakes/ (e.g., EepromPersistentStore round-trips via an in-memory FakeEeprom); golden-master tests on Mount still pass; firmware builds identical-sized binaries on at least one board (other variants ±1%).
Strangler-fig: move responsibilities out of Mount into core/ controllers, one at a time. Mount becomes a facade.
Recommended slice order (each is an independent step, parallelizable after Phase 3):
core/TrackingController— tracking speed, sidereal rate, tracker-stop compensation. OwnsIStepperAxis* trk.core/SlewController— slew state machine extracted from the 280-lineMount::loop. Inputs are target + geometry; outputs are stepper commands and state events.core/GuidingController— guide-pulse direction/speed math + timed completion (usesIClock).core/HomingController— hall-sensor/end-switch state machine; ownsIHomingSensor* ra,IHomingSensor* dec.core/ParkingController— park position, parking transitions.core/FocusController— focus motor (only constructed when focus axis is present).core/AzAltController— AZ/ALT motors (only constructed when present).core/MountState— single source of truth for the_mountStatusbitfield, with typed enum API (Status::isSlewing()etc.). Controllers mutateMountState; Mount facade reads it.core/EventBus— controllers publishPositionChanged,SlewStarted,Parked, etc.; display adapter subscribes (removes Mount → display direct coupling).
Each step: extract → add focused unit tests with FakeIt-faked ports → remove the original code from Mount.cpp → ship.
Verify per step: unit tests for the new controller; golden-master tests on Mount still green; firmware behavior on hardware unchanged (manual smoke checklist).
Eliminate #ifdef axes in core/, ports/, and most of adapters/. Feature flags survive only in the composition root and in HAL backend selection.
- Replace
AZ_STEPPER_TYPE,ALT_STEPPER_TYPE,FOCUS_STEPPER_TYPEchecks: composition root either constructs the controller and injects it, or injects a null-object controller.core/code calls unconditionally. - Replace
*_DRIVER_TYPEchecks with selection of the rightIMotorDriverimplementation at composition time (Tmc2209DrivervsNullMotorDriver). USE_HALL_SENSOR_*_AUTOHOME,USE_*_END_SWITCH→ presence of a non-null port implementation.- Truly board-specific code (interrupt registers, board pins) lives only inside the relevant
hal/<platform>/backend;core/,ports/, andadapters/become#ifdef-free. - Add a
MountConfigbuilder inapp/that reads theConfiguration*.hppmacros and produces a runtime config object.
Verify: core/ and ports/ contain zero #ifdef for features (CI grep check); all 5 existing board matrix builds still pass; binary size delta within budget (set explicit per-board limit, e.g., +3% allowed).
The parser is already in core/. This phase finishes the Meade slice by refining the executor and transport layers.
MeadeCommandProcessor(current adapter) is already insrc/root implementingIMeadeHandlers→ move it tosrc/adapters/MeadeCommandAdapterto match layer conventions.- Introduce
adapters/SerialTransport+adapters/WifiTransportto feed bytes tocore/meade/MeadeParser; parsed commands dispatch to the adapter. - Remove
Mount* _mountraw pointer fromMeadeCommandProcessor— replace with port-based interfaces from Phase 3/4 (e.g.IMeadeHandlersimplemented over controller interfaces, not the god-object). - Remove the legacy
Mount::delay()blocking call from GPS acquisition handler — replace withIClock-based non-blocking state machine. - The existing 13 test files in
unit_tests/test_meade/already cover all parser families. Add integration tests wiringSerialTransport→MeadeParser→MeadeCommandAdapterwith faked ports.
Verify: Meade test suite green (existing 13 files + new integration tests); Stellarium/ASCOM round-trip smoke test (manual) recorded as a regression checklist; coverage of core/meade/ ≥ 80% (already achieved).
- Mount facade slimmed to a thin compat shim (or removed if no external dependents).
- Move display-related code paths off the Mount → display direct call into the
EventBus. - Architecture doc (
docs/architecture.md) with the layer diagram, port catalog, and "where to add a new feature" guide. - Ratchet CI coverage gate to its final value (
core/≥ 85%,adapters/≥ 40%).
Verify: Architecture doc reviewed; CI gates final; full board matrix green; smoke-tested on at least one real mount.
src/core/meade/— 16 files: parser, 12 family dispatchers, helpers, protocol spec, typed handler interfacesunit_tests/test_meade/— 13 test files covering all parser families with fake handler stubs
src/Mount.cpp,src/Mount.hpp— the god-object being decomposed;loop(),calculateRAandDECSteppers(),guidePulse(),startSlewing(),readPersistentData()are the biggest extraction targets.src/Sidereal.cpp,src/DayTime.cpp,src/Declination.cpp,src/Latitude.cpp,src/Longitude.cpp— already pure; move intocore/in Phase 2.src/EPROMStore.cpp,src/EPROMStore.hpp— already a good seam; becomesIPersistentStore+ adapter.
src/HallSensorHoming.cpp,src/EndSwitches.cpp,src/Gyro.cpp,src/LcdMenu.cpp,src/SSD1306_128x64_Display.cpp,src/WifiControl.cpp,src/LcdButtons.cpp— become adapters behind ports.src/Core.cpp,src/a_inits.hpp,src/b_setup.hpp,src/f_serial.hpp— wiring code gradually migrates intosrc/app/.
src/MeadeCommandProcessor.cpp,src/MeadeCommandProcessor.hpp— adapter already bridges parser toMount; move tosrc/adapters/and wire through ports.src/f_serial.hpp— serial framing code that callsMeadeCommandProcessor::instance()->processCommand(); becomesSerialTransportadapter.
platformio.ini—nativeenv with coverage flags, ArduinoFaketest_lib_deps, coverage extra script..github/workflows/ci.yml— runspio run -e native -t coverage+ publishes summary; builds all 5 boards.unit_tests/test_common/— expand with FakeIt-based port fakes for Phase 3+.Configuration.hpp,Configuration_adv.hpp— read once byMountConfigbuilder in Phase 5.
Automated:
pio test -e native -v— full host test suite, runs every PR.pio run -e <board>for the existing 5-board matrix — must remain green every phase.- Coverage gate via gcovr in CI — ratchets up phase by phase;
core/final target ≥ 85%. - CI grep check:
core/contains zero#include <Arduino.h>or#ifdef <FEATURE_FLAG>(after Phase 5). - Binary-size budget check per board (warn at +1%, fail at +3%).
Manual smoke checklist (per shippable phase end):
- Mount slews to known coordinates within tolerance on real hardware (one volunteer-owned board).
- Park / unpark cycle.
- Guide pulses in all four directions produce expected micro-moves.
- Stellarium/ASCOM LX200 round-trip (date, time, RA/DEC, sync) succeeds.
- Hall-sensor auto-home succeeds (on a board so equipped).
- Test stack: Unity (already in PIO) + FakeIt (via ArduinoFake) for mocking Arduino/library calls. No GoogleTest.
- Migration style: Hybrid — extract pure logic in place first (Phase 1–2), then strangler-fig hardware-coupled layers (Phase 3–6).
- Config flags: Migrate to runtime polymorphism behind interfaces; composition root reads the
Configuration*.hppmacros once.core/becomes#ifdef-free for features. - Back-compat: Meade serial protocol behavior is invariant (external interface); internal C++ APIs may change freely.
- Out of scope: UI menu screens (
c*_menu*.hpp) refactor; new features; supporting new boards; replacing AccelStepper/TMCStepper libraries; switching build system.
- C++ standard.
core/benefits from at least C++17 (std::optional,std::variant,if constexpr). PlatformIO defaults vary by board (some AVR ports stuck on C++11/14). Recommendation: setbuild_flags = -std=gnu++17for thenativeenv; verify each board env supports it (likely yes on current toolchains) — fall back to-std=gnu++14+ tagged unions if AVR pinches. - Binary size on AVR_MEGA2560. Polymorphism + extra indirection costs flash on AVR. Recommendation: keep vtables small (≤ ~12 ports), mark adapters
final, allow link-time devirtualization. If we still bust the budget, accept template-based static dispatch for the hot path (SlewController<RaAxis, DecAxis>) — adds complexity but keeps AVR shipping. - Interrupt-driven stepping.
InterruptAccelSteppermutates state from ISR context. Ports for axes need explicit thread/ISR-safety contract documented;core/controllers must never assume single-threaded access to axis state. Recommendation: document this inports/IStepperAxis.h; add aSnapshot()method returning a consistent state read.