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 into core/ first, then test (invert the typical "test-first" order because src/ root files transitively include <Arduino.h> via inc/Globals.hpp and are untestable in the 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.
| # | Goal | Rationale |
|---|---|---|
| 1 | Make domain logic host-testable | The native PIO env can't compile src/ root files (they transitively include <Arduino.h> via inc/Globals.hpp). Pure logic must live in src/core/ with zero Arduino deps so tests run in milliseconds, not on hardware. |
| 2 | Shrink the Mount god-object |
Mount.cpp is ~5000 LOC holding all domain behavior. End state: Mount is a thin facade (≤ 500 LOC) over focused controllers in core/. |
| 3 | Eliminate #ifdef from domain code |
Feature flags for axes, driver types, and sensors currently produce 2^N untested build combinations. These become runtime polymorphism behind interfaces — the composition root makes one choice; core/ never branches. |
| 4 | Swap hardware without touching logic | Changing stepper library, display type, or EEPROM backend must not require edits to core/ or ports/. |
| 5 | Coverage that means something | Target: core/ ≥ 85% line coverage with tests that verify behavior (not tautologies). |
| Category | Constraint | Why |
|---|---|---|
| Hardware | Must build & run on all 5 boards (AVR_MEGA2560, MKS_GEN_L_V1/V2/V21, ESP32_ESP32DEV) | Existing user base; CI matrix enforces this. |
| Memory | AVR_MEGA2560 has 256 KB flash, 8 KB SRAM | Flash overflow is caught at link time. Mitigation: keep vtable count ≤ ~12, mark adapters final, consider template-based static dispatch as escape hatch. |
| C++ standard | C++17 for core/ (needs etl::optional, if constexpr); boards may require C++14 fallback with ETL polyfills |
native env already uses -std=gnu++17. Board envs will be audited per phase. |
| Testability | core/ must never include <Arduino.h> or use String, Serial, millis(), LOG() |
Enforced by CI grep check. native env only compiles core/, ports/, adapters/. |
| Back-compat | Meade LX200 serial protocol behavior is invariant | External interfaces (Stellarium, ASCOM) must not break. Internal C++ APIs may change freely. |
| Shippable increments | Every phase/step must be independently shippable — all boards green, all tests green | No flag days; thin overlays preserve old call-sites during transition. |
| Tooling | PlatformIO + Googletest + FakeIt (bundled with ArduinoFake) + gcovr | Already configured in platformio.ini and CI (ci.yml). |
Dependencies point inward only. No layer may reference a layer above it.
graph TD
APP["app/<br/>Composition Root"]
ADAPTERS["adapters/<br/>Port → HAL glue"]
PORTS["ports/<br/>Domain interfaces"]
CORE["core/<br/>Pure domain logic"]
HAL["hal/<br/>Hardware abstraction<br/>interfaces + backends"]
HW["Hardware / Libraries<br/>Arduino, AccelStepper, TMCStepper,<br/>EEPROM, SSD1306, TinyGPS++"]
APP --> ADAPTERS
APP --> HAL
APP --> PORTS
APP --> CORE
ADAPTERS --> PORTS
ADAPTERS --> HAL
CORE --> PORTS
HAL --> HW
style CORE fill:#e1f5fe
style PORTS fill:#fff9c4
style ADAPTERS fill:#dcedc8
style HAL fill:#f3e5f5
style APP fill:#ffe0b2
style HW fill:#e0e0e0
| Layer | Directory | Depends on | Contains | Test strategy |
|---|---|---|---|---|
| core/ | src/core/ |
ports/ only |
Domain controllers (SlewController, TrackingController, …), algorithms (SiderealClock, CoordinateMath, …), MeadeParser, MountState, EventBus, MountConfig |
Pure unit tests with FakeIt-faked ports. No hardware, no Arduino. native env. |
| ports/ | src/ports/ |
nothing | Domain interfaces: IClock, ILogger, IPersistentStore, IStepperAxis, IMotorDriver, IDisplay, IHomingSensor, IEndSwitch, IGps, ITransport |
Pure abstract interfaces — no implementation to test. |
| adapters/ | src/adapters/ |
ports/ + hal/ |
Thin glue: AccelStepperAxis, EepromPersistentStore, SerialLogger, Tmc2209Driver, HallHomingSensor, SerialTransport, WifiTransport, MeadeCommandAdapter |
Integration tests: adapter → faked HAL port. |
| hal/ | src/hal/ |
hardware libraries only | Interfaces (IGpioPin, ISerialPort, IStepperMotor, ITmcDriver, ISystemClock, ITimerService, …) + per-platform backends (hal/arduino/, hal/avr/, hal/esp32/) |
Not host-tested (wraps hardware). Verified by on-device smoke tests. |
| app/ | src/app/ |
all layers | Composition root. Board-specific configuration comes from a header (e.g. Configuration_local_oaeboardv1.hpp, selected by the PIO environment) which populates a runtime MountConfig struct. Wires up the object graph. Replaces the legacy .ino entry point. |
Verified by on-device smoke tests across all 5 boards. |
Why both hal/ and ports/:
hal/describes what the hardware can do (pin toggles, UART bytes, timer ticks). One backend per platform.ports/describes what the domain needs (axis position, persistent value, "now"). Adapters compose one or more HAL services to satisfy a port — e.g.AccelStepperAxisimplementsIStepperAxisusingIStepperMotor+ITimerServicefrom HAL.- This split means a new stepper library requires a new HAL backend, not a port change. A new domain requirement (e.g. "axis acceleration profile") requires a port change, not a HAL change.
| Rule | Enforcement |
|---|---|
core/ never includes <Arduino.h>, String, Serial, millis(), LOG(), or any board pin header |
CI grep check (Phase 4+) |
core/ and ports/ contain zero #ifdef for feature flags |
CI grep check (Phase 4+) |
Time = IClock port (backed by hal::ISystemClock); no direct millis() |
Interface contract |
Logging = ILogger port (backed by hal::ISerialPort); no direct Serial.print() |
Interface contract |
Configuration = runtime MountConfig struct populated once in app/ from Configuration*.hpp macros |
Single translation unit reads macros |
Optional axes (AZ, ALT, Focus) = etl::optional or null-object pattern; no #ifdef branches |
Composition root constructs or injects null |
ISR-safety: IStepperAxis::Snapshot() returns an ISR-safe state snapshot; mutation methods are main-loop-only |
Settled in Phase 2 before Phase 3 controller extraction |
Mount becomes a thin facade (≤ 500 LOC) over core/ controllers, retained for Meade back-compat, gradually deprecated |
Shrinks each phase as controllers are extracted |
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_core/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_core/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.
The original plan called for testing before extraction. That doesn't work here: every src/ root file includes inc/Globals.hpp → <Arduino.h>, and uses String/LOG()/Arduino APIs. The native PIO env only builds core/, ports/, adapters/ — not src/ root files. New approach: extract pure subsets into core/ first, then write exhaustive tests. Each step is independently shippable. No behavior change.
Structure: Data containers (DayTime, Declination, Latitude, Longitude) go under src/core/types/. Algorithm modules (SiderealClock, CoordinateMath, CalendarMath, CoordinateFormatter) live at src/core/ root. EEPROM layout constants (EepromLayout) live in src/hal/ — they describe a storage format, not domain behavior.
src/core/
├── meade/ # ✅ already extracted — parser + protocol
├── types/ # NEW — pure data containers
│ ├── DayTime.hpp/.cpp
│ ├── Declination.hpp/.cpp
│ ├── Latitude.hpp/.cpp
│ └── Longitude.hpp/.cpp
├── SiderealClock.hpp/.cpp # algorithm producing DayTime outputs
├── CoordinateMath.hpp/.cpp # algorithm consuming coordinate types
├── CalendarMath.hpp/.cpp # pure date arithmetic
├── CoordinateFormatter.hpp/.cpp # char-buffer formatting
The src/ originals become thin overlays: they #include the core/ version and add only Arduino-dependent methods (ParseFromMeade, ToString, formatString). This preserves back-compat — no flag day.
Parallel with Steps 5, 7.
What's pure (uses only long math, no Arduino types): constructors, getHours/getMinutes/getSeconds/getTotalHours/getTotalMinutes/getTotalSeconds, set, addHours/addMinutes/addSeconds, addTime/subtractTime, sign, checkHours.
What stays (uses Arduino String or LOG()): ParseFromMeade, ToString, formatString.
Plan:
- Create
src/core/types/DayTime.hpp/.cpp— pure header with arithmetic only. - Keep original
src/DayTime.hpp/cppas overlay:#include "core/types/DayTime.hpp"+ Arduino-dependent methods. - Write
unit_tests/test_core/test_daytime.cpp— construction, 24h wrap, negative hours, add/subtract across midnight.
Depends on Step 1.
All three inherit DayTime. Pure parts: checkHours() clamping, degree conversion, constructors. What stays: ParseFromMeade(String const&), ToString(), formatString().
Plan:
- Create
src/core/types/Declination.hpp/.cpp,Latitude.hpp/.cpp,Longitude.hpp/.cpp— each inheritscore::DayTime. - Old
src/Declination.hppbecomes:#include "core/types/Declination.hpp"+ParseFromMeade/ToString/formatStringoverlay. - Write
test_declination.cpp,test_latitude.cpp,test_longitude.cpp— clamping edges, hemisphere handling.
Depends on Step 1.
Sidereal.cpp is 130 lines. The GPS path (calculateByGPS) uses TinyGPS++. The pure math (calculateByDateAndTime, calculateHa, calculateTheta, calculateDeltaJd) uses only double + DayTime.
Plan:
- Create
src/core/SiderealClock.hpp/.cpp—calculateByDateAndTimeandcalculateHa. - Keep old
src/Sidereal.cppwithcalculateByGPS+ thin wrappers delegating tocore::SiderealClock. - Create
test_core/test_sidereal.cpp— LST/HA from known dates (equinoxes, solstices), leap years.
Depends on Steps 1-2.
The ~200-line function computes stepper positions from RA/DEC targets. Core math is pure arithmetic on floats/longs (hemisphere, meridian flip, 3-solution selection).
Plan:
- Create
src/core/CoordinateMath.hpp/.cppwith plain structs (MountGeometry,EquatorialTarget,StepperSolution) and free functioncalculateStepperPositions(). Mount::calculateRAandDECSteppersbecomes a thin wrapper that fillsMountGeometryfrom member fields, calls the free function.- Write
test_core/test_coordinate_math.cpp— parametrized over hemisphere, meridian-flip boundary, pole targets.
Parallel with Steps 1, 7.
Mount::getLocalDate() handles year/month/day increment with month-length tables and leap-year logic. Pure integer math.
Plan:
- Create
src/core/CalendarMath.hpp/.cppwithstruct CalendarDateandaddDays(). Mount::getLocalDate()delegates to this.- Write
test_core/test_calendar.cpp— month boundaries, leap years incl. century rules.
Depends on Steps 1-2.
Mount::RAString() and Mount::DECString() format RA as HH:MM:SS# and DEC as sDD*MM:SS#. Pure char-buffer manipulation.
Plan:
- Create
src/core/CoordinateFormatter.hpp/.cppwithformatRA(char*, const DayTime&)andformatDEC(char*, const Declination&). - Old
RAString/DECStringallocateStringand call the formatter. - Write
test_core/test_coordinate_format.cpp— exact wire-byte output, sign rules, zero-padding.
Parallel with Steps 1, 5.
EPROMStore uses Arduino EEPROM API directly. Data-validation logic (magic markers 0xCE/0xCF, struct layouts) is separable.
Plan:
- Create
src/hal/EepromLayout.hpp— struct definitions and validation constants (no Arduino deps; EEPROM layout is a storage format, not domain logic). - Full
IPersistentStoreport + adapter comes in Phase 2.
Verify: pio test -e native -v — all new tests pass; pio run -e <board> for all 5 boards builds green per step; scripts/test-coverage.sh shows ≥ 85% line coverage on new core/ files; manual: mount slews to known coordinates on real hardware.
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). Migration note:Mountis currently accessed as a global (extern Mount mount;froma_inits.hpp). Call sites that usemount.xxx()will be updated to receive a pointer/reference. This uses the same thin-overlay pattern as Phase 1 — existing call sites delegate through the global during transition, then migrate to injected references when their owning module becomes an adapter. - Replace direct
millis(),digitalWrite(),EEPROMStore::calls insideMountwith port calls; replaceLOG()macro with_logger->log(...). - ISR contract for
IStepperAxis: This phase MUST settle the threading model.Mount::interruptLoop()is called from ISR context (AVR Timer ISR / ESP32 FreeRTOS task). TheIStepperAxisinterface must specify which methods are ISR-safe and which are main-loop-only.Snapshot()provides an ISR-safe state read. This contract is a prerequisite for Phase 3'sSlewController.
Verify: All Phase 1 tests still green; firmware builds all 5 boards (toolchain enforces flash limits).
Strangler-fig: move responsibilities out of Mount into core/ controllers, one at a time. Mount becomes a facade.
Prerequisite: Phase 2's ISR contract for IStepperAxis must be settled. The threading model for SlewController::update() (main loop vs ISR context) follows from that contract.
Recommended slice order (each is an independent step, parallelizable after Phase 2):
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. Threading model deferred to Phase 2's ISR contract.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; usesetl::optionalin composition root).core/AzAltController— AZ/ALT motors (only constructed when present; usesetl::optionalin composition root).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; all prior tests 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 (toolchain enforces flash limits).
The parser is already in core/. This phase finishes the Meade slice by refining the executor and transport layers.
Prerequisite: Phase 3 must be substantially complete. The MeadeCommandProcessor currently holds a Mount* raw pointer and calls Mount methods directly. It can only be re-wired to port-based interfaces once Phase 3's controllers expose those ports.
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 2/3 (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_core/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%).
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_core/meade/— 13 test files covering all parser families with fake handler stubs
src/DayTime.cpp,src/DayTime.hpp— extract pure arithmetic tocore/types/DayTime(Step 1)src/Declination.cpp,src/Latitude.cpp,src/Longitude.cpp— extract pure subsets tocore/types/(Step 2)src/Sidereal.cpp— extract pure math tocore/SiderealClock(Step 3)src/Mount.cpp— extractcalculateRAandDECStepperstocore/CoordinateMath(Step 4),getLocalDatetocore/CalendarMath(Step 5),RAString/DECStringtocore/CoordinateFormatter(Step 6)src/EPROMStore.cpp— extract validation logic tohal/EepromLayout.hpp(Step 7)
src/core/types/DayTime.hpp,src/core/types/DayTime.cppsrc/core/types/Declination.hpp,src/core/types/Declination.cppsrc/core/types/Latitude.hpp,src/core/types/Latitude.cppsrc/core/types/Longitude.hpp,src/core/types/Longitude.cppsrc/core/SiderealClock.hpp,src/core/SiderealClock.cppsrc/core/CoordinateMath.hpp,src/core/CoordinateMath.cppsrc/core/CalendarMath.hpp,src/core/CalendarMath.cppsrc/core/CoordinateFormatter.hpp,src/core/CoordinateFormatter.cppsrc/hal/EepromLayout.hppunit_tests/test_core/test_daytime.cpp,test_declination.cpp,test_latitude.cpp,test_longitude.cppunit_tests/test_core/test_sidereal.cpp,test_coordinate_math.cpp,test_calendar.cpp,test_coordinate_format.cpp
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,test_filter = test_core(catchestest_core/meade/too)..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 2+.Configuration.hpp,Configuration_adv.hpp— read once byMountConfigbuilder in Phase 4.
Automated:
pio test -e native -v— full host test suite, runs every PR. Filter:test_core(coverstest_core/meade/and newtest_core/tests).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 4).
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: Googletest + FakeIt (via ArduinoFake) for host-side unit tests. The
nativePIO env usestest_framework = googletest. FakeIt is bundled with ArduinoFake (ArduinoFake@^0.4.0) and provides the mocking/stubbing API (When(Method(...)).Return(...),Verify(Method(...))). No Unity. No GoogleMock. - Migration style: Hybrid — extract pure logic first (Phase 1), then strangler-fig hardware-coupled layers (Phase 2–5).
- Config flags: Migrate to runtime polymorphism behind interfaces; composition root reads the
Configuration*.hppmacros once.core/becomes#ifdef-free for features. - Optional axes: Use
etl::optional(from Embedded Template Library) — notstd::optional. The firmware builds with-D ETL_NO_STLon AVR targets.core/usesetl::optionalfor consistency across all build targets. Thenativetest env adds ETL as a dependency. - Mount global migration:
Mountis currently a global variable (extern Mount mount;ina_inits.hpp). Call sites that usemount.xxx()will be migrated using the thin-overlay pattern: existing code delegates through the global during transition; when a module becomes a proper adapter (Phase 2+), it receives injected references instead. No flag day needed. - ISR threading model: Deferred to Phase 2. The
IStepperAxisinterface contract (which methods are ISR-safe,Snapshot()semantics) will be settled before Phase 3 begins extracting controllers. Without this contract,SlewController's threading model is undefined. - 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. - Extract-before-test: All
src/root files includeinc/Globals.hpp→<Arduino.h>and are untestable in thenativePIO env (which only buildscore/,ports/,adapters/). Pure logic is extracted intocore/first, then tested — inverted from the typical test-first order. Only already-pure code (core/meade/,MappedDict) gets tests before extraction.
- C++ standard.
core/benefits from at least C++17 (etl::optional,if constexpr). PlatformIO defaults vary by board (some AVR ports stuck on C++11/14). Decision: native env already uses-std=gnu++17. Each board env will be checked for C++17 support and upgraded if needed. Fallback:-std=gnu++14+ ETL polyfills on boards that can't support C++17. - Binary size on AVR_MEGA2560. Polymorphism + extra indirection costs flash on AVR. The toolchain errors if flash is exceeded, so no explicit budget is needed. Mitigation: keep vtables small (≤ ~12 ports), mark adapters
final, allow link-time devirtualization. If flash still overflows, accept template-based static dispatch for the hot path (SlewController<RaAxis, DecAxis>) — adds complexity but keeps AVR shipping. - Characterization / golden-master tests. A future task: capture known-good (RA,DEC → stepper position) pairs from real hardware for the
calculateRAandDECSteppersmath. This would serve as regression tests for the meridian-flip solution selector — the highest-risk coordinate math in the system. Not part of Phase 1; left as a follow-up task after extraction is complete and the pure math is independently testable on native. - MeadeCommandProcessor singleton.
MeadeCommandProcessor::instance()is a true singleton (unlikeMount, which is a global). It's accessed fromf_serial.hpp,WifiControl.cpp,c_buttons.hpp, andtestmenu.cpp. Phase 5 moves it tosrc/adapters/MeadeCommandAdapter; the singleton pattern should be eliminated there in favor of a non-owning reference from the composition root.