From 99d954db42974d53e620c44c516afcb361f78842 Mon Sep 17 00:00:00 2001 From: Andre Stefanov Date: Wed, 27 May 2026 07:57:54 +0200 Subject: [PATCH 1/2] refactor: moved more logic components into core --- PLAN.md | 217 ++++++++++----- src/DayTime.cpp | 250 +---------------- src/DayTime.hpp | 59 +--- src/Declination.cpp | 51 +--- src/Declination.hpp | 34 +-- src/Latitude.cpp | 25 +- src/Latitude.hpp | 14 +- src/Longitude.cpp | 28 +- src/Longitude.hpp | 16 +- src/RightAscension.cpp | 96 +++++++ src/RightAscension.hpp | 21 ++ src/Sidereal.cpp | 56 +--- src/core/CalendarMath.cpp | 67 +++++ src/core/CalendarMath.hpp | 17 ++ src/core/CoordinateFormatter.cpp | 23 ++ src/core/CoordinateFormatter.hpp | 24 ++ src/core/CoordinateMath.cpp | 45 +++ src/core/CoordinateMath.hpp | 34 +++ src/core/EepromLayout.hpp | 54 ++++ src/core/SiderealClock.cpp | 65 +++++ src/core/SiderealClock.hpp | 22 ++ src/core/types/DayTime.cpp | 258 ++++++++++++++++++ src/core/types/DayTime.hpp | 81 ++++++ src/core/types/Declination.cpp | 51 ++++ src/core/types/Declination.hpp | 34 +++ src/core/types/Latitude.cpp | 34 +++ src/core/types/Latitude.hpp | 22 ++ src/core/types/Longitude.cpp | 34 +++ src/core/types/Longitude.hpp | 22 ++ src/core/types/RightAscension.cpp | 55 ++++ src/core/types/RightAscension.hpp | 30 ++ unit_tests/test_core/test_calendar.cpp | 103 +++++++ .../test_core/test_coordinate_formatter.cpp | 56 ++++ unit_tests/test_core/test_coordinate_math.cpp | 91 ++++++ unit_tests/test_core/test_eeprom_layout.cpp | 48 ++++ unit_tests/test_core/test_sidereal.cpp | 66 +++++ unit_tests/test_core/types/test_daytime.cpp | 180 ++++++++++++ .../test_core/types/test_declination.cpp | 64 +++++ unit_tests/test_core/types/test_latitude.cpp | 57 ++++ unit_tests/test_core/types/test_longitude.cpp | 58 ++++ .../test_core/types/test_right_ascension.cpp | 77 ++++++ 41 files changed, 2113 insertions(+), 526 deletions(-) create mode 100644 src/RightAscension.cpp create mode 100644 src/RightAscension.hpp create mode 100644 src/core/CalendarMath.cpp create mode 100644 src/core/CalendarMath.hpp create mode 100644 src/core/CoordinateFormatter.cpp create mode 100644 src/core/CoordinateFormatter.hpp create mode 100644 src/core/CoordinateMath.cpp create mode 100644 src/core/CoordinateMath.hpp create mode 100644 src/core/EepromLayout.hpp create mode 100644 src/core/SiderealClock.cpp create mode 100644 src/core/SiderealClock.hpp create mode 100644 src/core/types/DayTime.cpp create mode 100644 src/core/types/DayTime.hpp create mode 100644 src/core/types/Declination.cpp create mode 100644 src/core/types/Declination.hpp create mode 100644 src/core/types/Latitude.cpp create mode 100644 src/core/types/Latitude.hpp create mode 100644 src/core/types/Longitude.cpp create mode 100644 src/core/types/Longitude.hpp create mode 100644 src/core/types/RightAscension.cpp create mode 100644 src/core/types/RightAscension.hpp create mode 100644 unit_tests/test_core/test_calendar.cpp create mode 100644 unit_tests/test_core/test_coordinate_formatter.cpp create mode 100644 unit_tests/test_core/test_coordinate_math.cpp create mode 100644 unit_tests/test_core/test_eeprom_layout.cpp create mode 100644 unit_tests/test_core/test_sidereal.cpp create mode 100644 unit_tests/test_core/types/test_daytime.cpp create mode 100644 unit_tests/test_core/types/test_declination.cpp create mode 100644 unit_tests/test_core/types/test_latitude.cpp create mode 100644 unit_tests/test_core/types/test_longitude.cpp create mode 100644 unit_tests/test_core/types/test_right_ascension.cpp diff --git a/PLAN.md b/PLAN.md index a095dd65..d967579b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,7 +4,7 @@ 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. +Approach: **hybrid** — extract pure logic into `core/` first, then test (invert the typical "test-first" order because `src/` root files transitively include `` 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. --- @@ -52,7 +52,7 @@ Cross-cutting: - **Configuration** becomes a runtime `MountConfig` struct populated at composition time from `Configuration.hpp` constants (single translation unit reads the macros). `#ifdef` no longer leaks into `core/` or `ports/`; HAL backend selection is the only place feature flags survive. - **Time** is a `IClock` port backed by `hal::ISystemClock`; `core/` never calls `millis()` directly. - **Logging** is an `ILogger` port backed by `hal::ISerialPort`; `core/` never includes `Serial`. -- Optional axes (`AZ`, `ALT`, `Focus`) become `std::optional` or null-object pattern — no `#ifdef` branches in controllers. +- Optional axes (`AZ`, `ALT`, `Focus`) become `etl::optional` or null-object pattern — no `#ifdef` branches in controllers. `Mount.cpp` ends up as a thin **facade** (≤ 500 LOC) over `core/` controllers, retained for Meade protocol back-compat; gradually deprecated. @@ -66,7 +66,7 @@ The Meade LX200 command parser has been fully extracted into `src/core/meade/` a | 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/` | +| **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` | @@ -80,7 +80,7 @@ The `MeadeCommandProcessor` adapter bridges the parser to the legacy `Mount` sin ### Test coverage -13 test files in `unit_tests/test_meade/` provide comprehensive wire-byte coverage for every parser family using fake handler stubs. Tests verify: +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) @@ -108,35 +108,103 @@ The parser is pure and tested. The executor (`MeadeCommandProcessor`) is an adap **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. -### Phase 1 — Characterize existing pure logic (regression net) -*All pure-logic files identified by the audit get exhaustive tests before being moved.* +### Phase 1 — Extract pure domain logic into `core/`, then test +*The original plan called for testing before extraction. That doesn't work here: every `src/` root file includes `inc/Globals.hpp` → ``, 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.* -Steps (parallel after Phase 0): -1. Add Unity tests for `DayTime`, `Declination`, `Latitude`, `Longitude` (arithmetic, parse/format round-trips, sign edges, overflow). -2. Expand `test_sidereal.h` into full `Sidereal` coverage (LST/HA from date/time across edge dates, leap years, DST-irrelevant UTC). -3. Add tests for `MappedDict` boundary behaviors not yet covered. -4. Add **golden-master tests** for the largest pure-ish methods currently in `Mount.cpp` by exercising them as-is through a thin test driver: - - `Mount::calculateRAandDECSteppers` (parametrize over hemisphere, meridian-flip, latitude, target). - - `Mount::syncPosition` math. - - `Mount::getLocalDate` calendar increment (leap years, year/month wrap). - - `Mount::DECString` / `Mount::RAString` formatting. - These tests link against a stripped-down `Mount` compiled with ArduinoFake — they fail the moment behavior shifts during extraction. +**Structure:** Data containers (`DayTime`, `Declination`, `Latitude`, `Longitude`) go under `src/core/types/`. Algorithm modules (`SiderealClock`, `CoordinateMath`, `CalendarMath`, `CoordinateFormatter`, `EepromLayout`) live at `src/core/` root. Mirrors the `core/meade/` subfolder convention. -**Verify:** All new tests green; coverage report shows non-trivial line coverage on the listed methods; CI threshold ratcheted up. +``` +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 +└── EepromLayout.hpp # struct layouts & validation constants +``` + +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. + +#### Step 1: DayTime — extract pure arithmetic subset +*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:** +1. Create `src/core/types/DayTime.hpp/.cpp` — pure header with arithmetic only. +2. Keep original `src/DayTime.hpp/cpp` as overlay: `#include "core/types/DayTime.hpp"` + Arduino-dependent methods. +3. Write `unit_tests/test_core/test_daytime.cpp` — construction, 24h wrap, negative hours, add/subtract across midnight. + +#### Step 2: Declination, Latitude, Longitude — extract pure subsets +*Depends on Step 1.* + +All three inherit `DayTime`. Pure parts: `checkHours()` clamping, degree conversion, constructors. What stays: `ParseFromMeade(String const&)`, `ToString()`, `formatString()`. + +**Plan:** +1. Create `src/core/types/Declination.hpp/.cpp`, `Latitude.hpp/.cpp`, `Longitude.hpp/.cpp` — each inherits `core::DayTime`. +2. Old `src/Declination.hpp` becomes: `#include "core/types/Declination.hpp"` + `ParseFromMeade`/`ToString`/`formatString` overlay. +3. Write `test_declination.cpp`, `test_latitude.cpp`, `test_longitude.cpp` — clamping edges, hemisphere handling. + +#### Step 3: Sidereal — extract pure math, leave GPS behind +*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:** +1. Create `src/core/SiderealClock.hpp/.cpp` — `calculateByDateAndTime` and `calculateHa`. +2. Keep old `src/Sidereal.cpp` with `calculateByGPS` + thin wrappers delegating to `core::SiderealClock`. +3. Create `test_core/test_sidereal.cpp` — LST/HA from known dates (equinoxes, solstices), leap years. + +#### Step 4: Mount — extract `calculateRAandDECSteppers` math +*Depends on Steps 1-2.* -### Phase 2 — Extract pure domain (in place → `src/core/`) -*Move characterized logic into `core/` with no behavior change. Tests from Phase 1 prevent regressions.* +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). -1. Create `core/CoordinateMath` from `Mount::calculateRAandDECSteppers` + helpers; replace original with a thin delegate. Inputs/outputs as plain structs (`MountGeometry`, `EquatorialTarget`, `StepperTarget`). -2. Create `core/SiderealClock` wrapping `Sidereal::` statics behind an instance API that takes an `IClock`. -3. Create `core/CalendarMath` from `Mount::getLocalDate`. -4. Create `core/CoordinateFormatter` from RA/DEC string formatters. -5. Create `core/MountGeometry` value type holding steps-per-degree, calibration angles, hemisphere, backlash — replaces scattered Mount fields used by math. -6. ~~Create `core/MeadeParser` by splitting `MeadeCommandProcessor`: pure tokenize/dispatch lookup tables in `core/`; execution stays in adapter for now.~~ **✅ DONE** — Meade parser is already in `core/meade/` with 12 family-specific handler interfaces, 13 comprehensive test files, and `MeadeCommandProcessor` as the adapter implementing `IMeadeHandlers`. +**Plan:** +1. Create `src/core/CoordinateMath.hpp/.cpp` with plain structs (`MountGeometry`, `EquatorialTarget`, `StepperSolution`) and free function `calculateStepperPositions()`. +2. `Mount::calculateRAandDECSteppers` becomes a thin wrapper that fills `MountGeometry` from member fields, calls the free function. +3. Write `test_core/test_coordinate_math.cpp` — parametrized over hemisphere, meridian-flip boundary, pole targets. -**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. +#### Step 5: Mount — extract calendar math +*Parallel with Steps 1, 7.* -### Phase 3 — Introduce HAL, Ports & Adapters +`Mount::getLocalDate()` handles year/month/day increment with month-length tables and leap-year logic. Pure integer math. + +**Plan:** +1. Create `src/core/CalendarMath.hpp/.cpp` with `struct CalendarDate` and `addDays()`. +2. `Mount::getLocalDate()` delegates to this. +3. Write `test_core/test_calendar.cpp` — month boundaries, leap years incl. century rules. + +#### Step 6: Mount — extract coordinate formatting +*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:** +1. Create `src/core/CoordinateFormatter.hpp/.cpp` with `formatRA(char*, const DayTime&)` and `formatDEC(char*, const Declination&)`. +2. Old `RAString`/`DECString` allocate `String` and call the formatter. +3. Write `test_core/test_coordinate_format.cpp` — exact wire-byte output, sign rules, zero-padding. + +#### Step 7: EPROMStore — extract validation logic +*Parallel with Steps 1, 5.* + +`EPROMStore` uses Arduino EEPROM API directly. Data-validation logic (magic markers `0xCE`/`0xCF`, struct layouts) is separable. + +**Plan:** +1. Create `src/core/EepromLayout.hpp` — struct definitions and validation constants (no Arduino deps). +2. Full `IPersistentStore` port + adapter comes in Phase 2. +3. Write `test_core/test_eeprom_layout.cpp` — struct size checks, magic marker validation. + +**Verify:** `pio test -e native -v` — all new tests pass; `pio run -e ` 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. + +### Phase 2 — Introduce HAL, Ports & Adapters *Define the HAL surface, define the domain ports, and route current call sites through them. Keep current behavior bit-for-bit.* 1. Define **HAL interfaces** in `src/hal/`: @@ -163,30 +231,33 @@ Steps (parallel after Phase 0): - `LcdMenuDisplay`, `Ssd1306InfoDisplay` (over `hal::IOledPanel` / `hal::ICharLcd`), - `SerialTransport`, `WifiTransport` (over `hal::ISerialPort` / `hal::IWifiStack`), - `TinyGpsAdapter`. -5. Refactor `Mount` to **hold port pointers** (`IStepperAxis* _ra; IClock* _clock; ...`) injected at construction instead of owning concrete types. Composition happens in `app/` (currently `b_setup.hpp`). +5. Refactor `Mount` to **hold port pointers** (`IStepperAxis* _ra; IClock* _clock; ...`) injected at construction instead of owning concrete types. Composition happens in `app/` (currently `b_setup.hpp`). **Migration note:** `Mount` is currently accessed as a global (`extern Mount mount;` from `a_inits.hpp`). Call sites that use `mount.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. 6. Replace direct `millis()`, `digitalWrite()`, `EEPROMStore::` calls inside `Mount` with port calls; replace `LOG()` macro with `_logger->log(...)`. +7. **ISR contract for `IStepperAxis`:** This phase MUST settle the threading model. `Mount::interruptLoop()` is called from ISR context (AVR Timer ISR / ESP32 FreeRTOS task). The `IStepperAxis` interface 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's `SlewController`. -**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%). +**Verify:** All Phase 1 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`); firmware builds all 5 boards (toolchain enforces flash limits). -### Phase 4 — Decompose `Mount` into controllers +### Phase 3 — Decompose `Mount` into controllers *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): +**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): 1. `core/TrackingController` — tracking speed, sidereal rate, tracker-stop compensation. Owns `IStepperAxis* trk`. -2. `core/SlewController` — slew state machine extracted from the 280-line `Mount::loop`. Inputs are target + geometry; outputs are stepper commands and state events. +2. `core/SlewController` — slew state machine extracted from the 280-line `Mount::loop`. Inputs are target + geometry; outputs are stepper commands and state events. **Threading model deferred to Phase 2's ISR contract.** 3. `core/GuidingController` — guide-pulse direction/speed math + timed completion (uses `IClock`). 4. `core/HomingController` — hall-sensor/end-switch state machine; owns `IHomingSensor* ra`, `IHomingSensor* dec`. 5. `core/ParkingController` — park position, parking transitions. -6. `core/FocusController` — focus motor (only constructed when focus axis is present). -7. `core/AzAltController` — AZ/ALT motors (only constructed when present). +6. `core/FocusController` — focus motor (only constructed when focus axis is present; uses `etl::optional` in composition root). +7. `core/AzAltController` — AZ/ALT motors (only constructed when present; uses `etl::optional` in composition root). 8. `core/MountState` — single source of truth for the `_mountStatus` bitfield, with typed enum API (`Status::isSlewing()` etc.). Controllers mutate `MountState`; Mount facade reads it. 9. `core/EventBus` — controllers publish `PositionChanged`, `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). +**Verify per step:** unit tests for the new controller; all prior tests still green; firmware behavior on hardware unchanged (manual smoke checklist). -### Phase 5 — Compile-time flags → runtime polymorphism +### Phase 4 — Compile-time flags → runtime polymorphism *Eliminate `#ifdef` axes in `core/`, `ports/`, and most of `adapters/`. Feature flags survive only in the composition root and in HAL backend selection.* 1. Replace `AZ_STEPPER_TYPE`, `ALT_STEPPER_TYPE`, `FOCUS_STEPPER_TYPE` checks: composition root either constructs the controller and injects it, or injects a **null-object** controller. `core/` code calls unconditionally. @@ -195,20 +266,22 @@ Each step: extract → add focused unit tests with FakeIt-faked ports → remove 4. Truly board-specific code (interrupt registers, board pins) lives **only inside the relevant `hal//` backend**; `core/`, `ports/`, and `adapters/` become `#ifdef`-free. 5. Add a `MountConfig` builder in `app/` that reads the `Configuration*.hpp` macros 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). +**Verify:** `core/` and `ports/` contain zero `#ifdef` for features (CI grep check); all 5 existing board matrix builds still pass (toolchain enforces flash limits). -### Phase 6 — Meade execution layer cleanup +### Phase 5 — Meade execution layer cleanup *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. + 1. `MeadeCommandProcessor` (current adapter) is already in `src/` root implementing `IMeadeHandlers` → move it to `src/adapters/MeadeCommandAdapter` to match layer conventions. 2. Introduce `adapters/SerialTransport` + `adapters/WifiTransport` to feed bytes to `core/meade/MeadeParser`; parsed commands dispatch to the adapter. -3. Remove `Mount* _mount` raw pointer from `MeadeCommandProcessor` — replace with port-based interfaces from Phase 3/4 (e.g. `IMeadeHandlers` implemented over controller interfaces, not the god-object). +3. Remove `Mount* _mount` raw pointer from `MeadeCommandProcessor` — replace with port-based interfaces from Phase 2/3 (e.g. `IMeadeHandlers` implemented over controller interfaces, not the god-object). 4. Remove the legacy `Mount::delay()` blocking call from GPS acquisition handler — replace with `IClock`-based non-blocking state machine. -5. The existing 13 test files in `unit_tests/test_meade/` already cover all parser families. Add integration tests wiring `SerialTransport` → `MeadeParser` → `MeadeCommandAdapter` with faked ports. +5. The existing 13 test files in `unit_tests/test_core/meade/` already cover all parser families. Add integration tests wiring `SerialTransport` → `MeadeParser` → `MeadeCommandAdapter` with 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). -### Phase 7 — Cleanup & documentation +### Phase 6 — Cleanup & documentation 1. Mount facade slimmed to a thin compat shim (or removed if no external dependents). 2. Move display-related code paths off the Mount → display direct call into the `EventBus`. 3. Architecture doc (`docs/architecture.md`) with the layer diagram, port catalog, and "where to add a new feature" guide. @@ -222,37 +295,52 @@ Each step: extract → add focused unit tests with FakeIt-faked ports → remove ### Already migrated to `core/` - [`src/core/meade/`](src/core/meade/) — 16 files: parser, 12 family dispatchers, helpers, protocol spec, typed handler interfaces -- [`unit_tests/test_meade/`](unit_tests/test_meade/) — 13 test files covering all parser families with fake handler stubs - -### Phase 0–1 targets -- [`src/Mount.cpp`](src/Mount.cpp), [`src/Mount.hpp`](src/Mount.hpp) — the god-object being decomposed; `loop()`, `calculateRAandDECSteppers()`, `guidePulse()`, `startSlewing()`, `readPersistentData()` are the biggest extraction targets. -- [`src/Sidereal.cpp`](src/Sidereal.cpp), [`src/DayTime.cpp`](src/DayTime.cpp), [`src/Declination.cpp`](src/Declination.cpp), [`src/Latitude.cpp`](src/Latitude.cpp), [`src/Longitude.cpp`](src/Longitude.cpp) — already pure; move into `core/` in Phase 2. -- [`src/EPROMStore.cpp`](src/EPROMStore.cpp), [`src/EPROMStore.hpp`](src/EPROMStore.hpp) — already a good seam; becomes `IPersistentStore` + adapter. - -### Phase 2–3 targets +- [`unit_tests/test_core/meade/`](unit_tests/test_core/meade/) — 13 test files covering all parser families with fake handler stubs + +### Phase 1 targets — extract & test +- [`src/DayTime.cpp`](src/DayTime.cpp), [`src/DayTime.hpp`](src/DayTime.hpp) — extract pure arithmetic to `core/types/DayTime` (Step 1) +- [`src/Declination.cpp`](src/Declination.cpp), [`src/Latitude.cpp`](src/Latitude.cpp), [`src/Longitude.cpp`](src/Longitude.cpp) — extract pure subsets to `core/types/` (Step 2) +- [`src/Sidereal.cpp`](src/Sidereal.cpp) — extract pure math to `core/SiderealClock` (Step 3) +- [`src/Mount.cpp`](src/Mount.cpp) — extract `calculateRAandDECSteppers` to `core/CoordinateMath` (Step 4), `getLocalDate` to `core/CalendarMath` (Step 5), `RAString`/`DECString` to `core/CoordinateFormatter` (Step 6) +- [`src/EPROMStore.cpp`](src/EPROMStore.cpp) — extract validation logic to `core/EepromLayout.hpp` (Step 7) + +### New files created in Phase 1 +- `src/core/types/DayTime.hpp`, `src/core/types/DayTime.cpp` +- `src/core/types/Declination.hpp`, `src/core/types/Declination.cpp` +- `src/core/types/Latitude.hpp`, `src/core/types/Latitude.cpp` +- `src/core/types/Longitude.hpp`, `src/core/types/Longitude.cpp` +- `src/core/SiderealClock.hpp`, `src/core/SiderealClock.cpp` +- `src/core/CoordinateMath.hpp`, `src/core/CoordinateMath.cpp` +- `src/core/CalendarMath.hpp`, `src/core/CalendarMath.cpp` +- `src/core/CoordinateFormatter.hpp`, `src/core/CoordinateFormatter.cpp` +- `src/core/EepromLayout.hpp` +- `unit_tests/test_core/test_daytime.cpp`, `test_declination.cpp`, `test_latitude.cpp`, `test_longitude.cpp` +- `unit_tests/test_core/test_sidereal.cpp`, `test_coordinate_math.cpp`, `test_calendar.cpp`, `test_coordinate_format.cpp` +- `unit_tests/test_core/test_eeprom_layout.cpp` + +### Phase 2 targets - [`src/HallSensorHoming.cpp`](src/HallSensorHoming.cpp), [`src/EndSwitches.cpp`](src/EndSwitches.cpp), [`src/Gyro.cpp`](src/Gyro.cpp), [`src/LcdMenu.cpp`](src/LcdMenu.cpp), [`src/SSD1306_128x64_Display.cpp`](src/SSD1306_128x64_Display.cpp), [`src/WifiControl.cpp`](src/WifiControl.cpp), [`src/LcdButtons.cpp`](src/LcdButtons.cpp) — become adapters behind ports. - [`src/Core.cpp`](src/Core.cpp), [`src/a_inits.hpp`](src/a_inits.hpp), [`src/b_setup.hpp`](src/b_setup.hpp), [`src/f_serial.hpp`](src/f_serial.hpp) — wiring code gradually migrates into `src/app/`. -### Phase 6 targets +### Phase 5 targets - [`src/MeadeCommandProcessor.cpp`](src/MeadeCommandProcessor.cpp), [`src/MeadeCommandProcessor.hpp`](src/MeadeCommandProcessor.hpp) — adapter already bridges parser to `Mount`; move to `src/adapters/` and wire through ports. - [`src/f_serial.hpp`](src/f_serial.hpp) — serial framing code that calls `MeadeCommandProcessor::instance()->processCommand()`; becomes `SerialTransport` adapter. ### Infrastructure -- [`platformio.ini`](platformio.ini) — `native` env with coverage flags, ArduinoFake `test_lib_deps`, coverage extra script. +- [`platformio.ini`](platformio.ini) — `native` env with coverage flags, ArduinoFake `test_lib_deps`, coverage extra script, `test_filter = test_core` (catches `test_core/meade/` too). - [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — runs `pio run -e native -t coverage` + publishes summary; builds all 5 boards. -- [`unit_tests/test_common/`](unit_tests/test_common/) — expand with FakeIt-based port fakes for Phase 3+. -- [`Configuration.hpp`](Configuration.hpp), [`Configuration_adv.hpp`](Configuration_adv.hpp) — read once by `MountConfig` builder in Phase 5. +- [`unit_tests/test_common/`](unit_tests/test_common/) — expand with FakeIt-based port fakes for Phase 2+. +- [`Configuration.hpp`](Configuration.hpp), [`Configuration_adv.hpp`](Configuration_adv.hpp) — read once by `MountConfig` builder in Phase 4. --- ## Verification strategy Automated: -1. `pio test -e native -v` — full host test suite, runs every PR. +1. `pio test -e native -v` — full host test suite, runs every PR. Filter: `test_core` (covers `test_core/meade/` and new `test_core/` tests). 2. `pio run -e ` for the existing 5-board matrix — must remain green every phase. 3. Coverage gate via gcovr in CI — ratchets up phase by phase; `core/` final target ≥ 85%. -4. CI grep check: `core/` contains zero `#include ` or `#ifdef ` (after Phase 5). -5. Binary-size budget check per board (warn at +1%, fail at +3%). +4. CI grep check: `core/` contains zero `#include ` or `#ifdef ` (after Phase 4). Manual smoke checklist (per shippable phase end): 1. Mount slews to known coordinates within tolerance on real hardware (one volunteer-owned board). @@ -265,14 +353,21 @@ Manual smoke checklist (per shippable phase end): ## Decisions -- **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). +- **Test stack:** **Googletest** + **FakeIt** (via ArduinoFake) for host-side unit tests. The `native` PIO env uses `test_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*.hpp` macros once. `core/` becomes `#ifdef`-free for features. +- **Optional axes:** Use `etl::optional` (from Embedded Template Library) — not `std::optional`. The firmware builds with `-D ETL_NO_STL` on AVR targets. `core/` uses `etl::optional` for consistency across all build targets. The `native` test env adds ETL as a dependency. +- **Mount global migration:** `Mount` is currently a global variable (`extern Mount mount;` in `a_inits.hpp`). Call sites that use `mount.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 `IStepperAxis` interface 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 include `inc/Globals.hpp` → `` and are untestable in the `native` PIO env (which only builds `core/`, `ports/`, `adapters/`). Pure logic is extracted into `core/` first, then tested — inverted from the typical test-first order. Only already-pure code (`core/meade/`, `MappedDict`) gets tests before extraction. +- **`core/types/` for data containers:** `DayTime`, `Declination`, `Latitude`, `Longitude` grouped under `src/core/types/` as pure data. Algorithm modules (`SiderealClock`, `CoordinateMath`, etc.) live at `core/` root. Mirrors the `core/meade/` subfolder convention. Can be split further if needed. +- **Thin overlays preserve back-compat:** Original `src/` files become overlays that `#include` the `core/` version and add only Arduino-dependent methods (`ParseFromMeade`, `ToString`, `formatString`). No flag day — each step is independently shippable. ## Further Considerations -1. **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:* set `build_flags = -std=gnu++17` for the `native` env; verify each board env supports it (likely yes on current toolchains) — fall back to `-std=gnu++14` + tagged unions if AVR pinches. -2. **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`) — adds complexity but keeps AVR shipping. -3. **Interrupt-driven stepping.** `InterruptAccelStepper` mutates 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 in `ports/IStepperAxis.h`; add a `Snapshot()` method returning a consistent state read. +1. **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. +2. **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`) — adds complexity but keeps AVR shipping. +3. **Characterization / golden-master tests.** A future task: capture known-good (RA,DEC → stepper position) pairs from real hardware for the `calculateRAandDECSteppers` math. 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. +4. **MeadeCommandProcessor singleton.** `MeadeCommandProcessor::instance()` is a true singleton (unlike `Mount`, which is a global). It's accessed from `f_serial.hpp`, `WifiControl.cpp`, `c_buttons.hpp`, and `testmenu.cpp`. Phase 5 moves it to `src/adapters/MeadeCommandAdapter`; the singleton pattern should be eliminated there in favor of a non-owning reference from the composition root. diff --git a/src/DayTime.cpp b/src/DayTime.cpp index 1197f256..512afe5f 100644 --- a/src/DayTime.cpp +++ b/src/DayTime.cpp @@ -4,10 +4,8 @@ #include "DayTime.hpp" /////////////////////////////////// -// DayTime (and Declination below) -// -// A class to handle hours, minutes, seconds in a unified manner, allowing -// addition of hours, minutes, seconds, other times and conversion to string. +// DayTime — Arduino-dependent overlay methods +// Pure arithmetic is in core::DayTime (src/core/types/DayTime.hpp/.cpp) // Parses the RA or DEC from a string that has an optional sign, a two digit degree, a seperator, a two digit minute, a seperator and a two digit second. // Does not correct for hemisphere (derived class Declination takes care of that) @@ -59,139 +57,6 @@ DayTime DayTime::ParseFromMeade(String const &s) return result; } -DayTime::DayTime() -{ - totalSeconds = 0; -} - -DayTime::DayTime(const DayTime &other) -{ - totalSeconds = other.totalSeconds; -} - -DayTime::DayTime(int h, int m, int s) -{ - long sgn = sign(h); - h = abs(h); - totalSeconds = sgn * ((60L * h + m) * 60L + s); -} - -DayTime::DayTime(float timeInHours) -{ - long sgn = fsign(timeInHours); - timeInHours = fabsf(timeInHours); - totalSeconds = sgn * static_cast(roundf(timeInHours * 60.0f * 60.0f)); -} - -int DayTime::getHours() const -{ - int h, m, s; - getTime(h, m, s); - return h; -} - -int DayTime::getMinutes() const -{ - int h, m, s; - getTime(h, m, s); - return m; -} - -int DayTime::getSeconds() const -{ - int h, m, s; - getTime(h, m, s); - return s; -} - -float DayTime::getTotalHours() const -{ - return 1.0f * totalSeconds / 3600.0f; -} - -float DayTime::getTotalMinutes() const -{ - return 1.0f * totalSeconds / 60.0f; -} - -long DayTime::getTotalSeconds() const -{ - return totalSeconds; -} - -void DayTime::getTime(int &h, int &m, int &s) const -{ - long seconds = labs(totalSeconds); - - h = (int) (seconds / 3600L); - seconds = seconds - (h * 3600L); - m = (int) (seconds / 60L); - s = (int) (seconds - (m * 60L)); - - h *= sign(totalSeconds); -} - -void DayTime::set(int h, int m, int s) -{ - DayTime dt(h, m, s); - totalSeconds = dt.totalSeconds; - checkHours(); -} - -void DayTime::set(const DayTime &other) -{ - totalSeconds = other.totalSeconds; - checkHours(); -} - -// Add hours, wrapping days (which are not tracked) -void DayTime::addHours(float deltaHours) -{ - totalSeconds += long(deltaHours * 3600L); - checkHours(); -} - -void DayTime::checkHours() -{ - while (totalSeconds >= secondsPerDay) - { - totalSeconds -= secondsPerDay; - } - - while (totalSeconds < 0) - { - totalSeconds += secondsPerDay; - } -} - -// Add minutes, wrapping hours if needed -void DayTime::addMinutes(int deltaMins) -{ - totalSeconds += deltaMins * 60; - checkHours(); -} - -// Add seconds, wrapping minutes and hours if needed -void DayTime::addSeconds(long deltaSecs) -{ - totalSeconds += deltaSecs; - checkHours(); -} - -// Add another time, wrapping seconds, minutes and hours if needed -void DayTime::addTime(const DayTime &other) -{ - totalSeconds += other.totalSeconds; - checkHours(); -} - -// Subtract another time, wrapping seconds, minutes and hours if needed -void DayTime::subtractTime(const DayTime &other) -{ - totalSeconds -= other.totalSeconds; - checkHours(); -} - char achBuf[32]; // Convert to a standard string (like 14:45:06) @@ -245,112 +110,7 @@ const char *DayTime::ToString() const snprintf(p, remaining, " (%s)", floatStr.c_str()); return achBuf; } -void DayTime::printTwoDigits(char *achDegs, int num) const -{ - achDegs[0] = '0' + (num / 10); - achDegs[1] = '0' + (num % 10); - achDegs[2] = 0; -} -const char *DayTime::formatStringImpl(char *targetBuffer, const char *format, char sgn, long degs, long mins, long secs) const -{ - char achDegs[5]; - char achMins[3]; - char achSecs[3]; - const char *f = format; - char *p = targetBuffer; - - int i = 0; - if (sgn != '\0') - { - achDegs[0] = sgn; - i++; - } - - long absdegs = labs(degs); - if (absdegs >= 100) - { - achDegs[i++] = '0' + min(9L, (absdegs / 100)); - absdegs = absdegs % 100; - } - - printTwoDigits(achDegs + i, absdegs); - printTwoDigits(achMins, mins); - printTwoDigits(achSecs, secs); - - char macro = '\0'; - bool inMacro = false; - while (*f) - { - switch (*f) - { - case '{': - { - inMacro = true; - } - break; - case '}': - { - if (inMacro) - { - switch (macro) - { - case '+': - { - *p++ = (degs < 0 ? '-' : '+'); - } - break; - case 'd': - { - strcpy(p, achDegs); - p += strlen(achDegs); - } - break; - case 'm': - { - strcpy(p, achMins); - p += 2; - } - break; - case 's': - { - strcpy(p, achSecs); - p += 2; - } - break; - } - inMacro = false; - } - } - break; - default: - { - if (inMacro) - { - macro = *f; - } - else - { - *p++ = *f; - } - } - } - f++; - } - - *p = '\0'; - return targetBuffer; -} - -const char *DayTime::formatString(char *targetBuffer, const char *format, long *pSecs) const -{ - long secs = pSecs == nullptr ? totalSeconds : *pSecs; - char sgn = secs < 0 ? '-' : '+'; - secs = labs(secs); - long degs = secs / 3600; - secs = secs - degs * 3600; - long mins = secs / 60; - secs = secs - mins * 60; - - return formatStringImpl(targetBuffer, format, sgn, degs, mins, secs); -} +// ParseFromMeade, formatString, formatStringImpl, printTwoDigits +// were previously here. formatString/formatStringImpl/printTwoDigits +// are now in core::DayTime (pure). diff --git a/src/DayTime.hpp b/src/DayTime.hpp index bfce4233..cc7dc894 100644 --- a/src/DayTime.hpp +++ b/src/DayTime.hpp @@ -1,68 +1,23 @@ #pragma once +#include "core/types/DayTime.hpp" + // A class to handle hours, minutes, seconds in a unified manner, allowing // addition of hours, minutes, seconds, other times and conversion to string. // Forward declarations class String; -// DayTime handles a 24-hour time. -class DayTime +/// Thin overlay over core::DayTime that adds Arduino-dependent methods +/// (String-based parsing, formatting, logging). +class DayTime : public core::DayTime { - protected: - long totalSeconds; - public: - DayTime(); - - DayTime(const DayTime &other); - DayTime(int h, int m, int s); - - // From hours - DayTime(float timeInHours); - - int getHours() const; - int getMinutes() const; - int getSeconds() const; - float getTotalHours() const; - float getTotalMinutes() const; - long getTotalSeconds() const; - - void getTime(int &h, int &m, int &s) const; - virtual void set(int h, int m, int s); - void set(const DayTime &other); - - // Add hours, wrapping days (which are not tracked). Negative or positive. - virtual void addHours(float deltaHours); - - // Add minutes, wrapping hours if needed - void addMinutes(int deltaMins); - - // Add seconds, wrapping minutes and hours if needed - void addSeconds(long deltaSecs); - - // Add time components, wrapping seconds, minutes and hours if needed - void addTime(int deltaHours, int deltaMinutes, int deltaSeconds); - - // Add another time, wrapping seconds, minutes and hours if needed - void addTime(const DayTime &other); - // Subtract another time, wrapping seconds, minutes and hours if needed - - void subtractTime(const DayTime &other); + // Inherit constructors from core + using core::DayTime::DayTime; // Convert to a standard string (like 14:45:06) virtual const char *ToString() const; - virtual const char *formatString(char *targetBuffer, const char *format, long *pSeconds = nullptr) const; - - //protected: - virtual void checkHours(); static DayTime ParseFromMeade(String const &s); - - protected: - const char *formatStringImpl(char *targetBuffer, const char *format, char sgn, long degs, long mins, long secs) const; - void printTwoDigits(char *achDegs, int num) const; - - private: - static long const secondsPerDay = 24L * 3600L; /// Real seconds (not sidereal) }; diff --git a/src/Declination.cpp b/src/Declination.cpp index 3970f07f..aa75f70c 100644 --- a/src/Declination.cpp +++ b/src/Declination.cpp @@ -23,52 +23,9 @@ // Celestial 90 60 30 0 -30 -60 -90 -60 -30 0 30 60 90 // Celestial = -90 + abs(Declination) // Declination -180 -150 -120 -90 -60 -30 0 30 60 90 120 150 180 -Declination::Declination() : DayTime() -{ -} - -Declination::Declination(const Declination &other) : DayTime(other) -{ -} - -Declination::Declination(int h, int m, int s) : DayTime(h, m, s) -{ -} - -Declination::Declination(float inDegrees) : DayTime(inDegrees) -{ -} - -void Declination::set(int h, int m, int s) -{ - Declination dt(h, m, s); - totalSeconds = dt.totalSeconds; - checkHours(); -} - -void Declination::addDegrees(int deltaDegrees) -{ - addHours(deltaDegrees); -} - -float Declination::getTotalDegrees() const -{ - return getTotalHours(); -} -void Declination::checkHours() -{ - if (totalSeconds > arcSecondsPerHemisphere) - { - LOG(DEBUG_GENERAL, "[DECLINATION]: CheckHours: Degrees is more than 180, clamping"); - totalSeconds = arcSecondsPerHemisphere; - } - if (totalSeconds < -arcSecondsPerHemisphere) - { - LOG(DEBUG_GENERAL, "[DECLINATION]: CheckHours: Degrees is less than -180, clamping"); - totalSeconds = -arcSecondsPerHemisphere; - } -} +// Pure arithmetic (constructors, set, addDegrees, getTotalDegrees, checkHours) +// is in core::Declination (src/core/types/Declination.hpp/.cpp). char achBufDeg[32]; @@ -100,7 +57,7 @@ Declination Declination::ParseFromMeade(String const &s) LOG(DEBUG_MEADE, "[DECLINATION]: Declination.Parse(%s) for %s Hemi", s.c_str(), inNorthernHemisphere ? "N" : "S"); // Use the DayTime code to parse it... - DayTime dt = DayTime::ParseFromMeade(s); + ::DayTime dt = ::DayTime::ParseFromMeade(s); LOG(DEBUG_MEADE, "[DECLINATION]: Declination DayTime is %l secs", dt.getTotalSeconds()); // ...and then correct for hemisphere @@ -125,5 +82,5 @@ const char *Declination::formatString(char *targetBuffer, const char *format, lo { long secs = inNorthernHemisphere ? (arcSecondsPerHemisphere / 2) - labs(totalSeconds) : -(arcSecondsPerHemisphere / 2) + labs(totalSeconds); - return DayTime::formatString(targetBuffer, format, &secs); + return core::DayTime::formatString(targetBuffer, format, &secs); } diff --git a/src/Declination.hpp b/src/Declination.hpp index 42c0fda5..8bb88b46 100644 --- a/src/Declination.hpp +++ b/src/Declination.hpp @@ -1,40 +1,24 @@ #pragma once +#include "core/types/Declination.hpp" #include "DayTime.hpp" -// Declination is used to store DEC coordinate. -// The range of time is from -180 degrees to 0 degrees. -// In the northern hemisphere, 0 is north pole, -180 is south pole -// In the southern hemisphere, 0 is south pole, -180 is north pole -class Declination : public DayTime +// Forward declarations +class String; + +/// Declination overlay — adds Arduino-dependent formatting and parsing +/// over core::Declination. +class Declination : public core::Declination { public: - Declination(); - Declination(const Declination &other); - Declination(int h, int m, int s); - Declination(float inDegrees); - - virtual void set(int h, int m, int s); - - // Add degrees, clamp to -180...0 - void addDegrees(int deltaDegrees); - - // Get total degrees (-180..0) - float getTotalDegrees() const; + using core::Declination::Declination; // Convert to a standard string (like +54:45:06) - virtual const char *ToString() const override; + virtual const char *ToString() const; virtual const char *formatString(char *targetBuffer, const char *format, long *pSeconds = nullptr) const; const char *ToDisplayString(char sep1, char sep2) const; - protected: - virtual void checkHours() override; - - public: static Declination ParseFromMeade(String const &s); static Declination FromSeconds(long seconds); - - private: - static long const arcSecondsPerHemisphere = 180L * 60L * 60L; // Arc-seconds in 180 degrees }; diff --git a/src/Latitude.cpp b/src/Latitude.cpp index b90db767..caaaee91 100644 --- a/src/Latitude.cpp +++ b/src/Latitude.cpp @@ -4,31 +4,20 @@ ////////////////////////////////////////////////////////////////////////////////////// // +// Latitude — Arduino-dependent overlay methods +// Pure arithmetic is in core::Latitude (src/core/types/Latitude.hpp/.cpp) +// // 90 is north pole, -90 is south pole -Latitude::Latitude(const Latitude &other) : DayTime(other) -{ -} - -Latitude::Latitude(int h, int m, int s) : DayTime(h, m, s) +Latitude::Latitude(const Latitude &other) : core::Latitude(other) { } -Latitude::Latitude(float inDegrees) : DayTime(inDegrees) +Latitude::Latitude(int h, int m, int s) : core::Latitude(h, m, s) { } -void Latitude::checkHours() +Latitude::Latitude(float inDegrees) : core::Latitude(inDegrees) { - if (totalSeconds > 90L * 3600L) - { - LOG(DEBUG_GENERAL, "[LATITUDE]: CheckHours: Degrees is more than 90, clamping"); - totalSeconds = 90L * 3600L; - } - if (totalSeconds < (-90L * 3600L)) - { - LOG(DEBUG_GENERAL, "[LATITUDE]: CheckHours: Degrees is less than -90, clamping"); - totalSeconds = -90L * 3600L; - } } Latitude Latitude::ParseFromMeade(String const &s) @@ -37,7 +26,7 @@ Latitude Latitude::ParseFromMeade(String const &s) LOG(DEBUG_MEADE, "[LATITUDE]: Latitude.Parse(%s)", s.c_str()); // Use the DayTime code to parse it. - DayTime dt = DayTime::ParseFromMeade(s); + ::DayTime dt = ::DayTime::ParseFromMeade(s); result.totalSeconds = dt.getTotalSeconds(); result.checkHours(); LOG(DEBUG_MEADE, "[LATITUDE]: Latitude.Parse(%s) -> %s", s.c_str(), result.ToString()); diff --git a/src/Latitude.hpp b/src/Latitude.hpp index 4e47a363..39e57bf8 100644 --- a/src/Latitude.hpp +++ b/src/Latitude.hpp @@ -1,12 +1,17 @@ #pragma once +#include "core/types/Latitude.hpp" #include "DayTime.hpp" -// 90 at north pole, -90 at south pole -class Latitude : public DayTime +// Forward declarations +class String; + +/// Latitude overlay — adds Arduino-dependent parsing over core::Latitude. +/// 90 at north pole, -90 at south pole. +class Latitude : public core::Latitude { public: - Latitude() : DayTime() + Latitude() : core::Latitude() { } Latitude(const Latitude &other); @@ -14,7 +19,4 @@ class Latitude : public DayTime Latitude(float inDegrees); static Latitude ParseFromMeade(String const &s); - - protected: - virtual void checkHours() override; }; diff --git a/src/Longitude.cpp b/src/Longitude.cpp index de019770..96c80665 100644 --- a/src/Longitude.cpp +++ b/src/Longitude.cpp @@ -4,32 +4,22 @@ ////////////////////////////////////////////////////////////////////////////////////// // -// -180..180 range, 0 is at the prime meridian (through Greenwich), negative going west, positive going east - -Longitude::Longitude(const Longitude &other) : DayTime(other) -{ -} +// Longitude — Arduino-dependent overlay methods +// Pure arithmetic is in core::Longitude (src/core/types/Longitude.hpp/.cpp) +// +// -180..180 range, 0 is at the prime meridian (through Greenwich), +// negative going west, positive going east -Longitude::Longitude(int h, int m, int s) : DayTime(h, m, s) +Longitude::Longitude(const Longitude &other) : core::Longitude(other) { } -Longitude::Longitude(float inDegrees) : DayTime(inDegrees) +Longitude::Longitude(int h, int m, int s) : core::Longitude(h, m, s) { } -void Longitude::checkHours() +Longitude::Longitude(float inDegrees) : core::Longitude(inDegrees) { - while (totalSeconds > 180L * 3600L) - { - LOG(DEBUG_GENERAL, "[LONGITUDE]: CheckHours: Degrees is more than 180, wrapping"); - totalSeconds -= 360L * 3600L; - } - while (totalSeconds < (-180L * 3600L)) - { - LOG(DEBUG_GENERAL, "[LONGITUDE]: CheckHours: Degrees is less than -180, wrapping"); - totalSeconds += 360L * 3600L; - } } Longitude Longitude::ParseFromMeade(String const &s) @@ -38,7 +28,7 @@ Longitude Longitude::ParseFromMeade(String const &s) LOG(DEBUG_MEADE, "[LONGITUDE]: Parse(%s)", s.c_str()); // Use the DayTime code to parse it. - DayTime dt = DayTime::ParseFromMeade(s); + ::DayTime dt = ::DayTime::ParseFromMeade(s); if ((s[0] == '-') || (s[0] == '+')) { diff --git a/src/Longitude.hpp b/src/Longitude.hpp index 3375a034..19661c7f 100644 --- a/src/Longitude.hpp +++ b/src/Longitude.hpp @@ -1,12 +1,19 @@ #pragma once +#include "core/types/Longitude.hpp" #include "DayTime.hpp" -// -180..180 range, 0 is at the prime meridian (through Greenwich), negative going west, positive going east -class Longitude : public DayTime +// Forward declarations +class String; + +/// Longitude overlay — adds Arduino-dependent formatting and parsing +/// over core::Longitude. +/// -180..180 range, 0 is at the prime meridian (through Greenwich), +/// negative going west, positive going east. +class Longitude : public core::Longitude { public: - Longitude() : DayTime() + Longitude() : core::Longitude() { } Longitude(const Longitude &other); @@ -18,7 +25,4 @@ class Longitude : public DayTime virtual const char *ToString() const; static Longitude ParseFromMeade(String const &s); - - protected: - virtual void checkHours() override; }; diff --git a/src/RightAscension.cpp b/src/RightAscension.cpp new file mode 100644 index 00000000..11be2303 --- /dev/null +++ b/src/RightAscension.cpp @@ -0,0 +1,96 @@ +#include "./inc/Globals.hpp" +#include "../Configuration.hpp" +#include "Utility.hpp" +#include "RightAscension.hpp" + +////////////////////////////////////////////////////////////////////////////////////// +// +// RightAscension — Arduino-dependent overlay methods +// Pure arithmetic is in core::RightAscension (src/core/types/RightAscension.hpp/.cpp) +// +// RA is 0–24h, always non-negative, wrapping at 24h. + +char achBufRA[32]; + +const char *RightAscension::ToString() const +{ + char *p = achBufRA; + int hours, mins, secs; + getTime(hours, mins, secs); + + // RA is always non-negative + int absHours = abs(hours); + if (absHours < 10) + { + *p++ = '0'; + } + else + { + *p++ = '0' + (absHours / 10); + } + *p++ = '0' + (absHours % 10); + + *p++ = ':'; + if (mins < 10) + { + *p++ = '0'; + } + else + { + *p++ = '0' + (mins / 10); + } + *p++ = '0' + (mins % 10); + + *p++ = ':'; + if (secs < 10) + { + *p++ = '0'; + } + else + { + *p++ = '0' + (secs / 10); + } + *p++ = '0' + (secs % 10); + *p = '\0'; + + size_t used = strlen(achBufRA); + size_t remaining = sizeof(achBufRA) - used; + if (remaining > 8) + { + String floatStr = String(this->getTotalHours(), 5); + snprintf(achBufRA + used, remaining, " (%s)", floatStr.c_str()); + } + return achBufRA; +} + +RightAscension RightAscension::ParseFromMeade(String const &s) +{ + RightAscension result; + int i = 0; + LOG(DEBUG_MEADE, "[RA]: Parse Coord from [%s]", s.c_str()); + + // RA never has a sign in Meade protocol + // Degs can be 2 or 3 digits + long degs = s[i++] - '0'; + degs = degs * 10 + s[i++] - '0'; + + // Third digit? + if ((s[i] >= '0') && (s[i] <= '9')) + { + degs = degs * 10 + s[i++] - '0'; + } + i++; // Skip separator + + int mins = s.substring(i, i + 2).toInt(); + int secs = 0; + if (int(s.length()) > i + 4) + { + secs = s.substring(i + 3, i + 5).toInt(); + } + + result.totalSeconds = ((degs * 60L + mins) * 60L) + secs; + result.checkHours(); + + LOG(DEBUG_MEADE, "[RA]: TotalSeconds are %l from %lh %dm %ds", result.totalSeconds, degs, mins, secs); + return result; +} diff --git a/src/RightAscension.hpp b/src/RightAscension.hpp new file mode 100644 index 00000000..fa0c1f98 --- /dev/null +++ b/src/RightAscension.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "core/types/RightAscension.hpp" +#include "DayTime.hpp" + +// Forward declarations +class String; + +/// Right Ascension overlay — adds Arduino-dependent formatting and parsing +/// over core::RightAscension. +/// RA is 0–24h, wrapping at 24h, always non-negative. +class RightAscension : public core::RightAscension +{ + public: + using core::RightAscension::RightAscension; + + // Convert to a standard string (like 12:34:56) + virtual const char *ToString() const; + + static RightAscension ParseFromMeade(String const &s); +}; diff --git a/src/Sidereal.cpp b/src/Sidereal.cpp index 535ebb26..03bfff24 100644 --- a/src/Sidereal.cpp +++ b/src/Sidereal.cpp @@ -1,14 +1,7 @@ #include "inc/Globals.hpp" #include "../Configuration.hpp" #include "Sidereal.hpp" - -// Constants for sidereal calculation -// Source: http://www.stargazing.net/kepler/altaz.html -const double C1 = 100.46; -const double C2 = 0.985647; -const double C3 = 15.0; -const double C4 = -0.5125; -const unsigned J2000 = 2000; +#include "core/SiderealClock.hpp" #if USE_GPS == 1 PUSH_NO_WARNINGS @@ -17,64 +10,33 @@ POP_NO_WARNINGS DayTime Sidereal::calculateByGPS(TinyGPSPlus *gps) { DayTime timeUTC = DayTime(gps->time.hour(), gps->time.minute(), gps->time.second()); - int deltaJd = calculateDeltaJd(gps->date.year(), gps->date.month(), gps->date.day()); + int deltaJd = core::SiderealClock::calculateDeltaJd(gps->date.year(), gps->date.month(), gps->date.day()); double deltaJ = static_cast(deltaJd) + (timeUTC.getTotalHours() / 24.0f); - return DayTime(static_cast(calculateTheta(deltaJ, gps->location.lng(), timeUTC.getTotalHours()) / 15.0)); + return DayTime(static_cast(core::SiderealClock::calculateTheta(deltaJ, gps->location.lng(), timeUTC.getTotalHours()) / 15.0)); } #endif // USE_GPS DayTime Sidereal::calculateByDateAndTime(double longitude, int year, int month, int day, DayTime *timeUTC) { - int deltaJd = calculateDeltaJd(year, month, day); + int deltaJd = core::SiderealClock::calculateDeltaJd(year, month, day); double deltaJ = deltaJd + ((timeUTC->getTotalHours()) / 24.0f); - return DayTime(static_cast(calculateTheta(deltaJ, longitude, timeUTC->getTotalHours()) / 15.0)); + return DayTime(static_cast(core::SiderealClock::calculateTheta(deltaJ, longitude, timeUTC->getTotalHours()) / 15.0)); } double Sidereal::calculateTheta(double deltaJ, double longitude, float timeUTC) { - double theta = C1; - theta += C2 * deltaJ; - theta += C3 * static_cast(timeUTC); - theta += C4; - theta += longitude; - return fmod(theta, 360.0); + return core::SiderealClock::calculateTheta(deltaJ, longitude, timeUTC); } int Sidereal::calculateDeltaJd(int year, int month, int day) { - const int daysInMonth[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; - - // Calculating days without leapdays - long int deltaJd = (year - J2000) * 365 + day; - for (int i = 0; i < month - 1; i++) - { - deltaJd += daysInMonth[i]; - } - - // Add leapdays - if (month <= 2) - { - // Not counting current year if we have not passed february yet - year--; - } - deltaJd += year / 4 - year / 100 + year / 400; - deltaJd -= J2000 / 4 - J2000 / 100 + J2000 / 400; - return deltaJd; + return core::SiderealClock::calculateDeltaJd(year, month, day); } DayTime Sidereal::calculateHa(float lstTotalHours) { - float lstDeg = lstTotalHours * 15; //to deg - - //subtract Poloars RA - lstDeg -= ((POLARIS_RA_SECOND / 3600.0f + POLARIS_RA_MINUTE / 60.0f + POLARIS_RA_HOUR) * 15.0f); - - //ensure positive deg - while (lstDeg < 0.0f) - { - lstDeg += 360.0f; - } + float lstDeg = static_cast( + core::SiderealClock::calculateHaDegrees(static_cast(lstTotalHours), POLARIS_RA_HOUR, POLARIS_RA_MINUTE, POLARIS_RA_SECOND)); - //update HA return DayTime(lstDeg / 15.0f); } \ No newline at end of file diff --git a/src/core/CalendarMath.cpp b/src/core/CalendarMath.cpp new file mode 100644 index 00000000..17182235 --- /dev/null +++ b/src/core/CalendarMath.cpp @@ -0,0 +1,67 @@ +#include "CalendarMath.hpp" + +namespace core +{ + +static int daysInMonth(int year, int month) +{ + switch (month) + { + case 2: + if (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)) + return 29; + return 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } +} + +CalendarDate addDays(CalendarDate date, long days) +{ + if (days >= 0) + { + date.day += static_cast(days); + while (true) + { + int maxDays = daysInMonth(date.year, date.month); + if (date.day > maxDays) + { + date.day -= maxDays; + date.month++; + if (date.month > 12) + { + date.month = 1; + date.year++; + } + } + else + { + break; + } + } + } + else + { + // Negative days: go backwards + date.day += static_cast(days); + while (date.day < 1) + { + date.month--; + if (date.month < 1) + { + date.month = 12; + date.year--; + } + date.day += daysInMonth(date.year, date.month); + } + } + + return date; +} + +} // namespace core diff --git a/src/core/CalendarMath.hpp b/src/core/CalendarMath.hpp new file mode 100644 index 00000000..1e0ffa79 --- /dev/null +++ b/src/core/CalendarMath.hpp @@ -0,0 +1,17 @@ +#pragma once + +namespace core +{ + +/// Pure date structure and calendar arithmetic (no Arduino deps). +struct CalendarDate { + int year; + int month; + int day; +}; + +/// Advance a date by the given number of days. +/// Handles month-length tables and leap-year logic including century rules. +CalendarDate addDays(CalendarDate date, long days); + +} // namespace core diff --git a/src/core/CoordinateFormatter.cpp b/src/core/CoordinateFormatter.cpp new file mode 100644 index 00000000..a7af38db --- /dev/null +++ b/src/core/CoordinateFormatter.cpp @@ -0,0 +1,23 @@ +#include "CoordinateFormatter.hpp" +#include "types/RightAscension.hpp" +#include "types/Declination.hpp" +#include + +namespace core +{ + +void formatRA(char *buf, const RightAscension &ra, const char *fmt) +{ + int h, m, s; + ra.getTime(h, m, s); + // RA is always non-negative in display (0–24h range) + int absHours = abs(h); + snprintf(buf, 32, fmt, absHours, m, s); +} + +void formatDEC(char *buf, const Declination &dec, const char *fmt) +{ + dec.formatString(buf, fmt); +} + +} // namespace core diff --git a/src/core/CoordinateFormatter.hpp b/src/core/CoordinateFormatter.hpp new file mode 100644 index 00000000..92d17949 --- /dev/null +++ b/src/core/CoordinateFormatter.hpp @@ -0,0 +1,24 @@ +#pragma once + +namespace core +{ + +/// Pure coordinate formatting to char buffers (no Arduino deps). +/// Formats RA as HH:MM:SS and DEC using the formatString mini-language. + +class RightAscension; // forward +class Declination; // forward + +/// Format RA (hours component) using a printf-style format string. +/// @param buf output buffer (must be large enough) +/// @param ra RA value (hours, minutes, seconds) +/// @param fmt printf format with 3 %d placeholders for h, m, s +void formatRA(char *buf, const RightAscension &ra, const char *fmt); + +/// Format DEC using the formatString mini-language ({d}, {m}, {s}, {+}). +/// @param buf output buffer +/// @param dec DEC value +/// @param fmt formatString format (e.g. "{d}*{m}'{s}#") +void formatDEC(char *buf, const Declination &dec, const char *fmt); + +} // namespace core diff --git a/src/core/CoordinateMath.cpp b/src/core/CoordinateMath.cpp new file mode 100644 index 00000000..179eb812 --- /dev/null +++ b/src/core/CoordinateMath.cpp @@ -0,0 +1,45 @@ +#include "CoordinateMath.hpp" + +namespace core +{ + +SlewResult calculateStepperPositions(const SlewGeometry &geo) +{ + SlewResult result; + + float moveRA = geo.moveRA; + float moveDEC = geo.moveDEC; + + // Pre-compute all three candidate solutions + result.solutions[0] = static_cast(-moveRA) * static_cast(geo.stepsPerSiderealHour); + result.solutions[1] = static_cast(moveDEC) * static_cast(geo.stepsPerDECDegree); + result.solutions[2] = static_cast(-(moveRA - 12.0f)) * static_cast(geo.stepsPerSiderealHour); + result.solutions[3] = static_cast(-moveDEC) * static_cast(geo.stepsPerDECDegree); + result.solutions[4] = static_cast(-(moveRA + 12.0f)) * static_cast(geo.stepsPerSiderealHour); + result.solutions[5] = static_cast(-moveDEC) * static_cast(geo.stepsPerDECDegree); + + // Select the appropriate solution based on RA limits + if (geo.homeTargetDeltaRA > geo.raLimitR) + { + // Past upper limit: flip both axes (Solution 2) + moveRA -= 12.0f; + moveDEC = -moveDEC; + } + else if (geo.homeTargetDeltaRA < geo.raLimitL) + { + // Past lower limit: flip both axes (Solution 3) + moveRA += 12.0f; + moveDEC = -moveDEC; + } + // else: Solution 1 (direct, no flip) + + // Apply DEC zero offset + moveDEC -= geo.zeroPosDEC; + + result.targetRASteps = static_cast(-moveRA * geo.stepsPerSiderealHour); + result.targetDECSteps = static_cast(moveDEC * geo.stepsPerDECDegree); + + return result; +} + +} // namespace core diff --git a/src/core/CoordinateMath.hpp b/src/core/CoordinateMath.hpp new file mode 100644 index 00000000..a5767774 --- /dev/null +++ b/src/core/CoordinateMath.hpp @@ -0,0 +1,34 @@ +#pragma once + +namespace core +{ + +/// Input geometry for the slew calculation (pure, no Arduino deps). +struct SlewGeometry { + float moveRA; /// Target RA in hours, normalized to [-12, 12] + float moveDEC; /// Target DEC in degrees (already hemisphere-corrected) + float homeTargetDeltaRA; /// Delta between target RA and tracked home position (hours) + float stepsPerSiderealHour; /// u-steps per sidereal hour + float stepsPerDECDegree; /// u-steps per DEC degree + float zeroPosDEC; /// Accumulated DEC offset from sync (degrees) + float raLimitL; /// Lower RA limit (hours, negative) + float raLimitR; /// Upper RA limit (hours, positive) +}; + +/// Result of the slew calculation. +struct SlewResult { + long targetRASteps; + long targetDECSteps; + + /// Three candidate solutions (6 values: RA,DEC, RA,DEC, RA,DEC) + long solutions[6]; +}; + +/// Calculate RA and DEC stepper target positions given the geometry. +/// Implements the meridian-flip solution selector: +/// - Solution 1: direct (no flip) +/// - Solution 2: flip when past upper limit (moveRA -= 12, moveDEC = -moveDEC) +/// - Solution 3: flip when past lower limit (moveRA += 12, moveDEC = -moveDEC) +SlewResult calculateStepperPositions(const SlewGeometry &geo); + +} // namespace core diff --git a/src/core/EepromLayout.hpp b/src/core/EepromLayout.hpp new file mode 100644 index 00000000..1c11f8ec --- /dev/null +++ b/src/core/EepromLayout.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +namespace core +{ + +/// Pure EEPROM layout constants (no Arduino deps). +/// Mirrors the enum definitions in EPROMStore.hpp for validation and testing. +struct EepromLayout { + // Magic marker values + static constexpr uint16_t MAGIC_MARKER_VALUE = 0xCE00; + static constexpr uint16_t MAGIC_MARKER_MASK = 0xFE00; + + // Storage size (bytes) + static constexpr int STORE_SIZE = 512; // Atmel: no explicit size needed; ESP32: 512 bytes + + // Item flags (bitfield indicating which fields have been stored) + enum ItemFlag : uint16_t + { + RA_STEPS_FLAG = 0x0001, + DEC_STEPS_FLAG = 0x0002, + SPEED_FACTOR_FLAG = 0x0004, + BACKLASH_STEPS_FLAG = 0x0008, + LATITUDE_FLAG = 0x0010, + LONGITUDE_FLAG = 0x0020, + PITCH_OFFSET_FLAG = 0x0040, + ROLL_OFFSET_FLAG = 0x0080, + EXTENDED_FLAG = 0x0100, + FLAGS_MASK = 0x01FF + }; + + // Extended item flags + enum ExtendedItemFlag : uint16_t + { + PARKING_POS_MARKER_FLAG = 0x0001, + DEC_LIMIT_MARKER_FLAG = 0x0002, + UTC_OFFSET_MARKER_FLAG = 0x0004, + RA_HOMING_MARKER_FLAG = 0x0008, + RA_NORM_STEPS_MARKER_FLAG = 0x0010, + DEC_NORM_STEPS_MARKER_FLAG = 0x0020, + DEC_HOMING_MARKER_FLAG = 0x0040, + LAST_FLASHED_MARKER_FLAG = 0x0080, + AZ_POSITION_MARKER_FLAG = 0x0100, + ALT_POSITION_MARKER_FLAG = 0x0200, + AZ_NORM_STEPS_MARKER_FLAG = 0x0400, + ALT_NORM_STEPS_MARKER_FLAG = 0x0800, + }; + + // Normalization factor for steps-per-degree storage + static constexpr float SteppingStorageNormalized = 25600.0; +}; + +} // namespace core diff --git a/src/core/SiderealClock.cpp b/src/core/SiderealClock.cpp new file mode 100644 index 00000000..61031413 --- /dev/null +++ b/src/core/SiderealClock.cpp @@ -0,0 +1,65 @@ +#include "SiderealClock.hpp" +#include + +namespace core +{ + +// Constants for sidereal calculation +// Source: http://www.stargazing.net/kepler/altaz.html +static const double C1 = 100.46; +static const double C2 = 0.985647; +static const double C3 = 15.0; +static const double C4 = -0.5125; +static const unsigned J2000 = 2000; + +double SiderealClock::calculateTheta(double deltaJ, double longitude, float timeUTC) +{ + double theta = C1; + theta += C2 * deltaJ; + theta += C3 * static_cast(timeUTC); + theta += C4; + theta += longitude; + return fmod(theta, 360.0); +} + +int SiderealClock::calculateDeltaJd(int year, int month, int day) +{ + const int daysInMonth[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + + // Calculating days without leapdays + long int deltaJd = (year - static_cast(J2000)) * 365 + day; + for (int i = 0; i < month - 1; i++) + { + deltaJd += daysInMonth[i]; + } + + // Add leapdays + if (month <= 2) + { + // Not counting current year if we have not passed february yet + year--; + } + deltaJd += year / 4 - year / 100 + year / 400; + deltaJd -= static_cast(J2000) / 4 - static_cast(J2000) / 100 + static_cast(J2000) / 400; + return static_cast(deltaJd); +} + +double SiderealClock::calculateHaDegrees(double lstTotalHours, int polarisRaHour, int polarisRaMinute, int polarisRaSecond) +{ + double lstDeg = lstTotalHours * 15.0; // to deg + + // subtract Polaris RA + lstDeg -= ((static_cast(polarisRaSecond) / 3600.0 + static_cast(polarisRaMinute) / 60.0 + + static_cast(polarisRaHour)) + * 15.0); + + // ensure positive deg + while (lstDeg < 0.0) + { + lstDeg += 360.0; + } + + return lstDeg; +} + +} // namespace core diff --git a/src/core/SiderealClock.hpp b/src/core/SiderealClock.hpp new file mode 100644 index 00000000..b43e79af --- /dev/null +++ b/src/core/SiderealClock.hpp @@ -0,0 +1,22 @@ +#pragma once + +namespace core +{ + +/// Pure sidereal time calculation (no Arduino/GPS deps). +/// Uses the algorithm from http://www.stargazing.net/kepler/altaz.html +class SiderealClock +{ + public: + /// Calculate Local Sidereal Time (LST) from date/time and longitude. + /// Returns DayTime representing the LST in hours. + static double calculateTheta(double deltaJ, double longitude, float timeUTC); + + /// Calculate delta-Julian days since J2000. + static int calculateDeltaJd(int year, int month, int day); + + /// Calculate hour angle for Polaris given LST total hours. + static double calculateHaDegrees(double lstTotalHours, int polarisRaHour = 3, int polarisRaMinute = 0, int polarisRaSecond = 8); +}; + +} // namespace core diff --git a/src/core/types/DayTime.cpp b/src/core/types/DayTime.cpp new file mode 100644 index 00000000..d0b29bde --- /dev/null +++ b/src/core/types/DayTime.cpp @@ -0,0 +1,258 @@ +#include "DayTime.hpp" + +namespace core +{ + +DayTime::DayTime() +{ + totalSeconds = 0; +} + +DayTime::DayTime(const DayTime &other) +{ + totalSeconds = other.totalSeconds; +} + +DayTime::DayTime(int h, int m, int s) +{ + long sgn = sign(h); + h = abs(h); + totalSeconds = sgn * ((60L * h + m) * 60L + s); +} + +DayTime::DayTime(float timeInHours) +{ + long sgn = fsign(timeInHours); + timeInHours = fabsf(timeInHours); + totalSeconds = sgn * static_cast(roundf(timeInHours * 60.0f * 60.0f)); +} + +int DayTime::getHours() const +{ + int h, m, s; + getTime(h, m, s); + return h; +} + +int DayTime::getMinutes() const +{ + int h, m, s; + getTime(h, m, s); + return m; +} + +int DayTime::getSeconds() const +{ + int h, m, s; + getTime(h, m, s); + return s; +} + +float DayTime::getTotalHours() const +{ + return 1.0f * totalSeconds / 3600.0f; +} + +float DayTime::getTotalMinutes() const +{ + return 1.0f * totalSeconds / 60.0f; +} + +long DayTime::getTotalSeconds() const +{ + return totalSeconds; +} + +void DayTime::getTime(int &h, int &m, int &s) const +{ + long seconds = labs(totalSeconds); + + h = (int) (seconds / 3600L); + seconds = seconds - (h * 3600L); + m = (int) (seconds / 60L); + s = (int) (seconds - (m * 60L)); + + h *= sign(totalSeconds); +} + +void DayTime::set(int h, int m, int s) +{ + DayTime dt(h, m, s); + totalSeconds = dt.totalSeconds; + checkHours(); +} + +void DayTime::set(const DayTime &other) +{ + totalSeconds = other.totalSeconds; + checkHours(); +} + +// Add hours, wrapping days (which are not tracked) +void DayTime::addHours(float deltaHours) +{ + totalSeconds += long(deltaHours * 3600L); + checkHours(); +} + +void DayTime::checkHours() +{ + while (totalSeconds >= secondsPerDay) + { + totalSeconds -= secondsPerDay; + } + + while (totalSeconds < 0) + { + totalSeconds += secondsPerDay; + } +} + +// Add minutes, wrapping hours if needed +void DayTime::addMinutes(int deltaMins) +{ + totalSeconds += deltaMins * 60; + checkHours(); +} + +// Add seconds, wrapping minutes and hours if needed +void DayTime::addSeconds(long deltaSecs) +{ + totalSeconds += deltaSecs; + checkHours(); +} + +// Add time components, wrapping seconds, minutes and hours if needed +void DayTime::addTime(int deltaHours, int deltaMinutes, int deltaSeconds) +{ + totalSeconds += ((60L * deltaHours + deltaMinutes) * 60L + deltaSeconds); + checkHours(); +} + +// Add another time, wrapping seconds, minutes and hours if needed +void DayTime::addTime(const DayTime &other) +{ + totalSeconds += other.totalSeconds; + checkHours(); +} + +// Subtract another time, wrapping seconds, minutes and hours if needed +void DayTime::subtractTime(const DayTime &other) +{ + totalSeconds -= other.totalSeconds; + checkHours(); +} + +void DayTime::printTwoDigits(char *achDegs, int num) const +{ + achDegs[0] = '0' + (num / 10); + achDegs[1] = '0' + (num % 10); + achDegs[2] = 0; +} + +const char *DayTime::formatStringImpl(char *targetBuffer, const char *format, char sgn, long degs, long mins, long secs) const +{ + char achDegs[5]; + char achMins[3]; + char achSecs[3]; + const char *f = format; + char *p = targetBuffer; + + int i = 0; + if (sgn != '\0') + { + achDegs[0] = sgn; + i++; + } + + long absdegs = labs(degs); + if (absdegs >= 100) + { + achDegs[i++] = '0' + (absdegs / 100); + if (absdegs / 100 > 9) + achDegs[i - 1] = '9'; + absdegs = absdegs % 100; + } + + printTwoDigits(achDegs + i, absdegs); + printTwoDigits(achMins, mins); + printTwoDigits(achSecs, secs); + + char macro = '\0'; + bool inMacro = false; + while (*f) + { + switch (*f) + { + case '{': + { + inMacro = true; + } + break; + case '}': + { + if (inMacro) + { + switch (macro) + { + case '+': + { + *p++ = (degs < 0 ? '-' : '+'); + } + break; + case 'd': + { + strcpy(p, achDegs); + p += strlen(achDegs); + } + break; + case 'm': + { + strcpy(p, achMins); + p += 2; + } + break; + case 's': + { + strcpy(p, achSecs); + p += 2; + } + break; + } + inMacro = false; + } + } + break; + default: + { + if (inMacro) + { + macro = *f; + } + else + { + *p++ = *f; + } + } + } + f++; + } + + *p = '\0'; + return targetBuffer; +} + +const char *DayTime::formatString(char *targetBuffer, const char *format, long *pSecs) const +{ + long secs = pSecs == nullptr ? totalSeconds : *pSecs; + char sgn = secs < 0 ? '-' : '+'; + secs = labs(secs); + long degs = secs / 3600; + secs = secs - degs * 3600; + long mins = secs / 60; + secs = secs - mins * 60; + + return formatStringImpl(targetBuffer, format, sgn, degs, mins, secs); +} + +} // namespace core diff --git a/src/core/types/DayTime.hpp b/src/core/types/DayTime.hpp new file mode 100644 index 00000000..55e98866 --- /dev/null +++ b/src/core/types/DayTime.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +namespace core +{ + +/// Pure (no Arduino deps) 24-hour time value stored as total seconds. +/// Handles hours/minutes/seconds arithmetic with 24h wrap-around. +class DayTime +{ + protected: + long totalSeconds; + + public: + DayTime(); + + DayTime(const DayTime &other); + DayTime(int h, int m, int s); + + // From hours (fractional) + DayTime(float timeInHours); + + int getHours() const; + int getMinutes() const; + int getSeconds() const; + float getTotalHours() const; + float getTotalMinutes() const; + long getTotalSeconds() const; + + void getTime(int &h, int &m, int &s) const; + virtual void set(int h, int m, int s); + void set(const DayTime &other); + + // Add hours, wrapping days (which are not tracked). Negative or positive. + virtual void addHours(float deltaHours); + + // Add minutes, wrapping hours if needed + void addMinutes(int deltaMins); + + // Add seconds, wrapping minutes and hours if needed + void addSeconds(long deltaSecs); + + // Add time components, wrapping seconds, minutes and hours if needed + void addTime(int deltaHours, int deltaMinutes, int deltaSeconds); + + // Add another time, wrapping seconds, minutes and hours if needed + void addTime(const DayTime &other); + + // Subtract another time, wrapping seconds, minutes and hours if needed + void subtractTime(const DayTime &other); + + // Format to char buffer (pure, no Arduino deps). Format tokens: + // {d}=degrees, {m}=minutes, {s}=seconds, {+}=sign. + virtual const char *formatString(char *targetBuffer, const char *format, long *pSeconds = nullptr) const; + + //protected: + virtual void checkHours(); + + protected: + const char *formatStringImpl(char *targetBuffer, const char *format, char sgn, long degs, long mins, long secs) const; + void printTwoDigits(char *achDegs, int num) const; + + private: + static long const secondsPerDay = 24L * 3600L; /// Real seconds (not sidereal) +}; + +// Inline helpers — equivalent to Utility.hpp sign/fsign but without Arduino deps. +inline int sign(long num) +{ + return num < 0 ? -1 : 1; +} + +inline int fsign(float num) +{ + return num < 0.0f ? -1 : 1; +} + +} // namespace core diff --git a/src/core/types/Declination.cpp b/src/core/types/Declination.cpp new file mode 100644 index 00000000..1e2a36ec --- /dev/null +++ b/src/core/types/Declination.cpp @@ -0,0 +1,51 @@ +#include "Declination.hpp" + +namespace core +{ + +Declination::Declination() : DayTime() +{ +} + +Declination::Declination(const Declination &other) : DayTime(other) +{ +} + +Declination::Declination(int h, int m, int s) : DayTime(h, m, s) +{ +} + +Declination::Declination(float inDegrees) : DayTime(inDegrees) +{ +} + +void Declination::set(int h, int m, int s) +{ + Declination dt(h, m, s); + totalSeconds = dt.totalSeconds; + checkHours(); +} + +void Declination::addDegrees(int deltaDegrees) +{ + addHours(deltaDegrees); +} + +float Declination::getTotalDegrees() const +{ + return getTotalHours(); +} + +void Declination::checkHours() +{ + if (totalSeconds > arcSecondsPerHemisphere) + { + totalSeconds = arcSecondsPerHemisphere; + } + if (totalSeconds < -arcSecondsPerHemisphere) + { + totalSeconds = -arcSecondsPerHemisphere; + } +} + +} // namespace core diff --git a/src/core/types/Declination.hpp b/src/core/types/Declination.hpp new file mode 100644 index 00000000..d9eeb2f8 --- /dev/null +++ b/src/core/types/Declination.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "DayTime.hpp" + +namespace core +{ + +/// Pure Declination coordinate. Range: -180° to +180° (arc-seconds), +/// clamped rather than wrapped. +/// 0 = pole, ±180 = opposite pole (hemisphere-dependent interpretation). +class Declination : public DayTime +{ + public: + Declination(); + Declination(const Declination &other); + Declination(int h, int m, int s); + Declination(float inDegrees); + + virtual void set(int h, int m, int s) override; + + // Add degrees, clamp to -180...180 + void addDegrees(int deltaDegrees); + + // Get total degrees (-180..180) + float getTotalDegrees() const; + + protected: + virtual void checkHours() override; + + protected: + static long const arcSecondsPerHemisphere = 180L * 60L * 60L; // Arc-seconds in 180 degrees +}; + +} // namespace core diff --git a/src/core/types/Latitude.cpp b/src/core/types/Latitude.cpp new file mode 100644 index 00000000..c7eca0ae --- /dev/null +++ b/src/core/types/Latitude.cpp @@ -0,0 +1,34 @@ +#include "Latitude.hpp" + +namespace core +{ + +Latitude::Latitude() : DayTime() +{ +} + +Latitude::Latitude(const Latitude &other) : DayTime(other) +{ +} + +Latitude::Latitude(int h, int m, int s) : DayTime(h, m, s) +{ +} + +Latitude::Latitude(float inDegrees) : DayTime(inDegrees) +{ +} + +void Latitude::checkHours() +{ + if (totalSeconds > 90L * 3600L) + { + totalSeconds = 90L * 3600L; + } + if (totalSeconds < (-90L * 3600L)) + { + totalSeconds = -90L * 3600L; + } +} + +} // namespace core diff --git a/src/core/types/Latitude.hpp b/src/core/types/Latitude.hpp new file mode 100644 index 00000000..168587ce --- /dev/null +++ b/src/core/types/Latitude.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "DayTime.hpp" + +namespace core +{ + +/// Pure Latitude coordinate. Range: -90° to +90° (clamped). +/// 90 = north pole, -90 = south pole. +class Latitude : public DayTime +{ + public: + Latitude(); + Latitude(const Latitude &other); + Latitude(int h, int m, int s); + Latitude(float inDegrees); + + protected: + virtual void checkHours() override; +}; + +} // namespace core diff --git a/src/core/types/Longitude.cpp b/src/core/types/Longitude.cpp new file mode 100644 index 00000000..84dee4b5 --- /dev/null +++ b/src/core/types/Longitude.cpp @@ -0,0 +1,34 @@ +#include "Longitude.hpp" + +namespace core +{ + +Longitude::Longitude() : DayTime() +{ +} + +Longitude::Longitude(const Longitude &other) : DayTime(other) +{ +} + +Longitude::Longitude(int h, int m, int s) : DayTime(h, m, s) +{ +} + +Longitude::Longitude(float inDegrees) : DayTime(inDegrees) +{ +} + +void Longitude::checkHours() +{ + while (totalSeconds > 180L * 3600L) + { + totalSeconds -= 360L * 3600L; + } + while (totalSeconds < (-180L * 3600L)) + { + totalSeconds += 360L * 3600L; + } +} + +} // namespace core diff --git a/src/core/types/Longitude.hpp b/src/core/types/Longitude.hpp new file mode 100644 index 00000000..6c52e44e --- /dev/null +++ b/src/core/types/Longitude.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "DayTime.hpp" + +namespace core +{ + +/// Pure Longitude coordinate. Range: -180° to +180° (wrapped). +/// 0 = prime meridian, negative = west, positive = east. +class Longitude : public DayTime +{ + public: + Longitude(); + Longitude(const Longitude &other); + Longitude(int h, int m, int s); + Longitude(float inDegrees); + + protected: + virtual void checkHours() override; +}; + +} // namespace core diff --git a/src/core/types/RightAscension.cpp b/src/core/types/RightAscension.cpp new file mode 100644 index 00000000..41734355 --- /dev/null +++ b/src/core/types/RightAscension.cpp @@ -0,0 +1,55 @@ +#include "RightAscension.hpp" + +namespace core +{ + +RightAscension::RightAscension() : DayTime() +{ +} + +RightAscension::RightAscension(const RightAscension &other) : DayTime(other) +{ +} + +RightAscension::RightAscension(int h, int m, int s) : DayTime(h, m, s) +{ + checkHours(); +} + +RightAscension::RightAscension(float timeInHours) : DayTime(timeInHours) +{ + checkHours(); +} + +void RightAscension::set(int h, int m, int s) +{ + RightAscension dt(h, m, s); + totalSeconds = dt.totalSeconds; + checkHours(); +} + +void RightAscension::addHours(float deltaHours) +{ + totalSeconds += static_cast(deltaHours * 3600L); + checkHours(); +} + +float RightAscension::getTotalHours() const +{ + return DayTime::getTotalHours(); +} + +void RightAscension::checkHours() +{ + while (totalSeconds >= 86400L) + { + totalSeconds -= 86400L; + } + + while (totalSeconds < 0) + { + totalSeconds += 86400L; + } +} + +} // namespace core diff --git a/src/core/types/RightAscension.hpp b/src/core/types/RightAscension.hpp new file mode 100644 index 00000000..1216f84e --- /dev/null +++ b/src/core/types/RightAscension.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "DayTime.hpp" + +namespace core +{ + +/// Pure Right Ascension coordinate. Range: 0h to 24h (wrapped). +/// Stored as total seconds, wrapping at 24h (86400 seconds). +class RightAscension : public DayTime +{ + public: + RightAscension(); + RightAscension(const RightAscension &other); + RightAscension(int h, int m, int s); + RightAscension(float timeInHours); + + virtual void set(int h, int m, int s) override; + + // Add hours, wrapping at 24h and keeping non-negative + void addHours(float deltaHours) override; + + // Get RA in hours (0..24) + float getTotalHours() const; + + protected: + virtual void checkHours() override; +}; + +} // namespace core diff --git a/unit_tests/test_core/test_calendar.cpp b/unit_tests/test_core/test_calendar.cpp new file mode 100644 index 00000000..db2c25de --- /dev/null +++ b/unit_tests/test_core/test_calendar.cpp @@ -0,0 +1,103 @@ +#include +#include "core/CalendarMath.hpp" + +using core::addDays; +using core::CalendarDate; + +TEST(CalendarMathTest, NoOverflow) +{ + CalendarDate d {2023, 6, 15}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2023, result.year); + EXPECT_EQ(6, result.month); + EXPECT_EQ(16, result.day); +} + +TEST(CalendarMathTest, MonthEndJanuary) +{ + CalendarDate d {2023, 1, 31}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2023, result.year); + EXPECT_EQ(2, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, FebruaryLeapYear) +{ + CalendarDate d {2020, 2, 28}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2020, result.year); + EXPECT_EQ(2, result.month); + EXPECT_EQ(29, result.day); + // One more day into March + result = addDays(result, 1); + EXPECT_EQ(2020, result.year); + EXPECT_EQ(3, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, FebruaryNonLeapYear) +{ + CalendarDate d {2021, 2, 28}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2021, result.year); + EXPECT_EQ(3, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, YearEnd) +{ + CalendarDate d {2023, 12, 31}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2024, result.year); + EXPECT_EQ(1, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, CenturyLeapRule) +{ + // 1900 is not a leap year (divisible by 100 but not 400) + CalendarDate d {1900, 2, 28}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(1900, result.year); + EXPECT_EQ(3, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, CenturyLeapRule400) +{ + // 2000 IS a leap year (divisible by 400) + CalendarDate d {2000, 2, 28}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2000, result.year); + EXPECT_EQ(2, result.month); + EXPECT_EQ(29, result.day); +} + +TEST(CalendarMathTest, AddMultipleDays) +{ + CalendarDate d {2023, 1, 1}; + CalendarDate result = addDays(d, 365); + // 2023 is not a leap year, so 365 days = Jan 1 2024 + EXPECT_EQ(2024, result.year); + EXPECT_EQ(1, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, MonthEnd30Day) +{ + CalendarDate d {2023, 4, 30}; + CalendarDate result = addDays(d, 1); + EXPECT_EQ(2023, result.year); + EXPECT_EQ(5, result.month); + EXPECT_EQ(1, result.day); +} + +TEST(CalendarMathTest, NegativeDays) +{ + CalendarDate d {2023, 1, 1}; + CalendarDate result = addDays(d, -1); + EXPECT_EQ(2022, result.year); + EXPECT_EQ(12, result.month); + EXPECT_EQ(31, result.day); +} diff --git a/unit_tests/test_core/test_coordinate_formatter.cpp b/unit_tests/test_core/test_coordinate_formatter.cpp new file mode 100644 index 00000000..3796681e --- /dev/null +++ b/unit_tests/test_core/test_coordinate_formatter.cpp @@ -0,0 +1,56 @@ +#include +#include +#include "core/CoordinateFormatter.hpp" +#include "core/types/RightAscension.hpp" +#include "core/types/Declination.hpp" + +using core::Declination; +using core::RightAscension; + +TEST(CoordinateFormatterTest, FormatRABasic) +{ + RightAscension ra(12, 34, 56); + char buf[32]; + core::formatRA(buf, ra, "%02d:%02d:%02d#"); + EXPECT_STREQ("12:34:56#", buf); +} + +TEST(CoordinateFormatterTest, FormatRAPadded) +{ + RightAscension ra(5, 7, 9); + char buf[32]; + core::formatRA(buf, ra, "%02d:%02d:%02d#"); + EXPECT_STREQ("05:07:09#", buf); +} + +TEST(CoordinateFormatterTest, FormatRALcdStyle) +{ + RightAscension ra(23, 59, 59); + char buf[32]; + core::formatRA(buf, ra, " %02dh %02dm %02ds"); + EXPECT_STREQ(" 23h 59m 59s", buf); +} + +TEST(CoordinateFormatterTest, FormatDECMeade) +{ + Declination dec(45, 30, 15); + char buf[32]; + core::formatDEC(buf, dec, "{d}*{m}'{s}#"); + EXPECT_STREQ("+45*30'15#", buf); +} + +TEST(CoordinateFormatterTest, FormatDECLcd) +{ + Declination dec(-15, 0, 0); + char buf[32]; + core::formatDEC(buf, dec, " {d}@ {m}' {s}\""); + EXPECT_STREQ(" -15@ 00' 00\"", buf); +} + +TEST(CoordinateFormatterTest, FormatDECCompact) +{ + Declination dec(5, 30, 0); + char buf[32]; + core::formatDEC(buf, dec, "{d}{m}{s}"); + EXPECT_STREQ("+053000", buf); +} diff --git a/unit_tests/test_core/test_coordinate_math.cpp b/unit_tests/test_core/test_coordinate_math.cpp new file mode 100644 index 00000000..4ff249c2 --- /dev/null +++ b/unit_tests/test_core/test_coordinate_math.cpp @@ -0,0 +1,91 @@ +#include +#include "core/CoordinateMath.hpp" + +using core::calculateStepperPositions; +using core::SlewGeometry; +using core::SlewResult; + +// Helper: create a default geometry for testing +static SlewGeometry makeGeo(float moveRA, float moveDEC, float deltaRA) +{ + SlewGeometry geo; + geo.moveRA = moveRA; + geo.moveDEC = moveDEC; + geo.homeTargetDeltaRA = deltaRA; + geo.stepsPerSiderealHour = 1000.0f; + geo.stepsPerDECDegree = 500.0f; + geo.zeroPosDEC = 0.0f; + geo.raLimitL = -6.0f; + geo.raLimitR = 6.0f; + return geo; +} + +TEST(CoordinateMathTest, Solution1Direct) +{ + // Target within limits: no flip needed + SlewGeometry geo = makeGeo(3.0f, 45.0f, 2.0f); + SlewResult result = calculateStepperPositions(geo); + + // moveRA = 3.0, moveDEC = 45.0 + EXPECT_EQ(static_cast(-3.0f * 1000.0f), result.targetRASteps); + EXPECT_EQ(static_cast(45.0f * 500.0f), result.targetDECSteps); +} + +TEST(CoordinateMathTest, Solution2FlipUpper) +{ + // Past upper limit: flip (moveRA -= 12, moveDEC = -moveDEC) + SlewGeometry geo = makeGeo(4.0f, 30.0f, 7.0f); // deltaRA=7 > raLimitR=6 + SlewResult result = calculateStepperPositions(geo); + + // moveRA = 4 - 12 = -8, moveDEC = -30 + EXPECT_EQ(static_cast(-(-8.0f) * 1000.0f), result.targetRASteps); + EXPECT_EQ(static_cast((-30.0f) * 500.0f), result.targetDECSteps); +} + +TEST(CoordinateMathTest, Solution3FlipLower) +{ + // Past lower limit: flip (moveRA += 12, moveDEC = -moveDEC) + SlewGeometry geo = makeGeo(-4.0f, 20.0f, -7.0f); // deltaRA=-7 < raLimitL=-6 + SlewResult result = calculateStepperPositions(geo); + + // moveRA = -4 + 12 = 8, moveDEC = -20 + EXPECT_EQ(static_cast(-8.0f * 1000.0f), result.targetRASteps); + EXPECT_EQ(static_cast((-20.0f) * 500.0f), result.targetDECSteps); +} + +TEST(CoordinateMathTest, ZeroPosDecOffset) +{ + SlewGeometry geo = makeGeo(2.0f, 30.0f, 1.0f); + geo.zeroPosDEC = 5.0f; // accumulated sync offset + SlewResult result = calculateStepperPositions(geo); + + // moveDEC = 30 - 5 = 25 + EXPECT_EQ(static_cast(25.0f * 500.0f), result.targetDECSteps); +} + +TEST(CoordinateMathTest, SolutionsArray) +{ + SlewGeometry geo = makeGeo(2.0f, 45.0f, 1.0f); + SlewResult result = calculateStepperPositions(geo); + + // Solution 1 + EXPECT_EQ(static_cast(-2.0f * 1000.0f), result.solutions[0]); + EXPECT_EQ(static_cast(45.0f * 500.0f), result.solutions[1]); + // Solution 2 + EXPECT_EQ(static_cast(-(2.0f - 12.0f) * 1000.0f), result.solutions[2]); + EXPECT_EQ(static_cast(-45.0f * 500.0f), result.solutions[3]); + // Solution 3 + EXPECT_EQ(static_cast(-(2.0f + 12.0f) * 1000.0f), result.solutions[4]); + EXPECT_EQ(static_cast(-45.0f * 500.0f), result.solutions[5]); +} + +TEST(CoordinateMathTest, BoundaryExactlyAtLimit) +{ + // Exactly at the limit: no flip (only > triggers, not >=) + SlewGeometry geo = makeGeo(3.0f, 30.0f, 6.0f); + SlewResult result = calculateStepperPositions(geo); + + // Should use solution 1 (direct) + EXPECT_EQ(static_cast(-3.0f * 1000.0f), result.targetRASteps); + EXPECT_EQ(static_cast(30.0f * 500.0f), result.targetDECSteps); +} diff --git a/unit_tests/test_core/test_eeprom_layout.cpp b/unit_tests/test_core/test_eeprom_layout.cpp new file mode 100644 index 00000000..7d9512b9 --- /dev/null +++ b/unit_tests/test_core/test_eeprom_layout.cpp @@ -0,0 +1,48 @@ +#include +#include "core/EepromLayout.hpp" + +using core::EepromLayout; + +TEST(EepromLayoutTest, MagicMarkerValue) +{ + EXPECT_EQ(0xCE00, EepromLayout::MAGIC_MARKER_VALUE); +} + +TEST(EepromLayoutTest, MagicMarkerMask) +{ + EXPECT_EQ(0xFE00, EepromLayout::MAGIC_MARKER_MASK); +} + +TEST(EepromLayoutTest, FlagsMaskCoversAll) +{ + uint16_t allFlags = EepromLayout::RA_STEPS_FLAG | EepromLayout::DEC_STEPS_FLAG | EepromLayout::SPEED_FACTOR_FLAG + | EepromLayout::BACKLASH_STEPS_FLAG | EepromLayout::LATITUDE_FLAG | EepromLayout::LONGITUDE_FLAG + | EepromLayout::PITCH_OFFSET_FLAG | EepromLayout::ROLL_OFFSET_FLAG | EepromLayout::EXTENDED_FLAG; + EXPECT_EQ(allFlags, allFlags & EepromLayout::FLAGS_MASK); +} + +TEST(EepromLayoutTest, ItemFlagValues) +{ + EXPECT_EQ(0x0001, EepromLayout::RA_STEPS_FLAG); + EXPECT_EQ(0x0002, EepromLayout::DEC_STEPS_FLAG); + EXPECT_EQ(0x0004, EepromLayout::SPEED_FACTOR_FLAG); + EXPECT_EQ(0x0008, EepromLayout::BACKLASH_STEPS_FLAG); + EXPECT_EQ(0x0010, EepromLayout::LATITUDE_FLAG); + EXPECT_EQ(0x0020, EepromLayout::LONGITUDE_FLAG); + EXPECT_EQ(0x0040, EepromLayout::PITCH_OFFSET_FLAG); + EXPECT_EQ(0x0080, EepromLayout::ROLL_OFFSET_FLAG); + EXPECT_EQ(0x0100, EepromLayout::EXTENDED_FLAG); +} + +TEST(EepromLayoutTest, ExtendedFlagValues) +{ + EXPECT_EQ(0x0001, EepromLayout::PARKING_POS_MARKER_FLAG); + EXPECT_EQ(0x0002, EepromLayout::DEC_LIMIT_MARKER_FLAG); + EXPECT_EQ(0x0004, EepromLayout::UTC_OFFSET_MARKER_FLAG); + EXPECT_EQ(0x0008, EepromLayout::RA_HOMING_MARKER_FLAG); +} + +TEST(EepromLayoutTest, NormalizedStepValue) +{ + EXPECT_FLOAT_EQ(25600.0f, EepromLayout::SteppingStorageNormalized); +} diff --git a/unit_tests/test_core/test_sidereal.cpp b/unit_tests/test_core/test_sidereal.cpp new file mode 100644 index 00000000..06c475f7 --- /dev/null +++ b/unit_tests/test_core/test_sidereal.cpp @@ -0,0 +1,66 @@ +#include +#include +#include "core/SiderealClock.hpp" + +using core::SiderealClock; + +// Known-good values sourced from stargazing.net/kepler/altaz.html +TEST(SiderealClockTest, DeltaJdJ2000) +{ + // J2000 epoch: 2000-01-01 should have deltaJd = 0 + int deltaJd = SiderealClock::calculateDeltaJd(2000, 1, 1); + EXPECT_EQ(0, deltaJd); +} + +TEST(SiderealClockTest, DeltaJdKnownDate) +{ + // Just verify it's deterministic and doesn't crash + int deltaJd = SiderealClock::calculateDeltaJd(2020, 3, 20); + EXPECT_GT(deltaJd, 0); +} + +TEST(SiderealClockTest, DeltaJdLeapYear) +{ + // 2020 is a leap year + int jan31 = SiderealClock::calculateDeltaJd(2020, 1, 31); + int feb1 = SiderealClock::calculateDeltaJd(2020, 2, 1); + EXPECT_EQ(1, feb1 - jan31); +} + +TEST(SiderealClockTest, DeltaJdMonotonic) +{ + int d1 = SiderealClock::calculateDeltaJd(2023, 1, 1); + int d2 = SiderealClock::calculateDeltaJd(2023, 12, 31); + EXPECT_LT(d1, d2); +} + +TEST(SiderealClockTest, ThetaOutputInRange) +{ + double theta = SiderealClock::calculateTheta(100.0, 0.0, 12.0f); + EXPECT_GE(theta, 0.0); + EXPECT_LT(theta, 360.0); +} + +TEST(SiderealClockTest, HaDegreesPositive) +{ + double ha = SiderealClock::calculateHaDegrees(0.0); + EXPECT_GE(ha, 0.0); + EXPECT_LT(ha, 360.0); +} + +TEST(SiderealClockTest, HaDegreesKnownPolaris) +{ + // At LST = 0h, Polaris HA should be roughly 360 - PolarisRA*15 in degrees + double ha = SiderealClock::calculateHaDegrees(0.0); + // Polaris RA is ~3h = 45 deg, so HA should be ~315 deg + EXPECT_NEAR(315.0, ha, 10.0); +} + +TEST(SiderealClockTest, HaDegreesCustomPolaris) +{ + // Use custom Polaris RA values + double ha1 = SiderealClock::calculateHaDegrees(0.0, 3, 0, 8); + double ha2 = SiderealClock::calculateHaDegrees(0.0, 6, 0, 0); + // Different RA => different HA + EXPECT_NE(ha1, ha2); +} diff --git a/unit_tests/test_core/types/test_daytime.cpp b/unit_tests/test_core/types/test_daytime.cpp new file mode 100644 index 00000000..1aed39ba --- /dev/null +++ b/unit_tests/test_core/types/test_daytime.cpp @@ -0,0 +1,180 @@ +#include +#include "core/types/DayTime.hpp" + +using core::DayTime; + +TEST(DayTimeTest, DefaultConstructor) +{ + DayTime dt; + EXPECT_EQ(0, dt.getTotalSeconds()); + EXPECT_EQ(0, dt.getHours()); + EXPECT_EQ(0, dt.getMinutes()); + EXPECT_EQ(0, dt.getSeconds()); +} + +TEST(DayTimeTest, HMSConstructor) +{ + DayTime dt(1, 30, 45); + EXPECT_EQ(1, dt.getHours()); + EXPECT_EQ(30, dt.getMinutes()); + EXPECT_EQ(45, dt.getSeconds()); +} + +TEST(DayTimeTest, FloatConstructor) +{ + DayTime dt(1.5f); // 1.5 hours = 1h 30m + EXPECT_EQ(1, dt.getHours()); + EXPECT_EQ(30, dt.getMinutes()); + EXPECT_EQ(0, dt.getSeconds()); +} + +TEST(DayTimeTest, CopyConstructor) +{ + DayTime dt1(2, 15, 30); + DayTime dt2(dt1); + EXPECT_EQ(dt1.getTotalSeconds(), dt2.getTotalSeconds()); +} + +TEST(DayTimeTest, GetTotalHours) +{ + DayTime dt(2, 30, 0); + EXPECT_FLOAT_EQ(2.5f, dt.getTotalHours()); +} + +TEST(DayTimeTest, GetTotalMinutes) +{ + DayTime dt(1, 30, 0); + EXPECT_FLOAT_EQ(90.0f, dt.getTotalMinutes()); +} + +TEST(DayTimeTest, SetHMS) +{ + DayTime dt; + dt.set(12, 34, 56); + EXPECT_EQ(12, dt.getHours()); + EXPECT_EQ(34, dt.getMinutes()); + EXPECT_EQ(56, dt.getSeconds()); +} + +TEST(DayTimeTest, AddHours) +{ + DayTime dt(1, 0, 0); + dt.addHours(2.5f); + EXPECT_EQ(3, dt.getHours()); + EXPECT_EQ(30, dt.getMinutes()); +} + +TEST(DayTimeTest, AddMinutes) +{ + DayTime dt(0, 30, 0); + dt.addMinutes(45); + EXPECT_EQ(1, dt.getHours()); + EXPECT_EQ(15, dt.getMinutes()); +} + +TEST(DayTimeTest, AddSeconds) +{ + DayTime dt(0, 0, 30); + dt.addSeconds(45); + EXPECT_EQ(1, dt.getMinutes()); + EXPECT_EQ(15, dt.getSeconds()); +} + +TEST(DayTimeTest, AddTimeComponents) +{ + DayTime dt(1, 30, 0); + dt.addTime(1, 45, 30); + EXPECT_EQ(3, dt.getHours()); + EXPECT_EQ(15, dt.getMinutes()); + EXPECT_EQ(30, dt.getSeconds()); +} + +TEST(DayTimeTest, AddTimeObject) +{ + DayTime dt1(1, 0, 0); + DayTime dt2(2, 30, 0); + dt1.addTime(dt2); + EXPECT_EQ(3, dt1.getHours()); + EXPECT_EQ(30, dt1.getMinutes()); +} + +TEST(DayTimeTest, SubtractTime) +{ + DayTime dt1(3, 0, 0); + DayTime dt2(1, 30, 0); + dt1.subtractTime(dt2); + EXPECT_EQ(1, dt1.getHours()); + EXPECT_EQ(30, dt1.getMinutes()); +} + +TEST(DayTimeTest, WrapAt24Hours) +{ + DayTime dt(22, 0, 0); + dt.addHours(4.0f); + EXPECT_EQ(2, dt.getHours()); +} + +TEST(DayTimeTest, WrapNegative) +{ + DayTime dt(1, 0, 0); + dt.subtractTime(DayTime(3, 0, 0)); + EXPECT_EQ(22, dt.getHours()); +} + +TEST(DayTimeTest, NegativeConstructor) +{ + // Constructor only uses sign of hours; abs() on hours, minutes as-is. + // (-1, -30, 0) = -1 * ((60*1 + (-30))*60 + 0) = -1800 sec + // getTime: labs(1800) => h=0, m=30, s=0, h*=sign(-1800)=0 + DayTime dt(-1, -30, -0); + EXPECT_EQ(0, dt.getHours()); + EXPECT_EQ(30, dt.getMinutes()); + EXPECT_EQ(0, dt.getSeconds()); +} + +TEST(DayTimeTest, NegativeFloatConstructor) +{ + // -1.5h = -5400 sec => h=-1, m=30 + DayTime dt(-1.5f); + EXPECT_EQ(-1, dt.getHours()); + EXPECT_EQ(30, dt.getMinutes()); +} + +TEST(DayTimeTest, SetWraps) +{ + // Constructor does NOT call checkHours(). set() does. + DayTime dt(25, 0, 0); + EXPECT_EQ(25, dt.getHours()); + // set() wraps + dt.set(25, 0, 0); + EXPECT_EQ(1, dt.getHours()); +} + +TEST(DayTimeTest, AddHoursNegative) +{ + DayTime dt(2, 0, 0); + dt.addHours(-3.0f); + EXPECT_EQ(23, dt.getHours()); +} + +TEST(DayTimeTest, GetTimeRef) +{ + DayTime dt(5, 15, 30); + int h, m, s; + dt.getTime(h, m, s); + EXPECT_EQ(5, h); + EXPECT_EQ(15, m); + EXPECT_EQ(30, s); +} + +TEST(DayTimeTest, GetTimeRefNegative) +{ + // (-2, -30, -15) = -1 * ((60*2 + (-30))*60 + (-15)) = -5385 sec + // labs(5385): h=1, rem=1785, m=29, s=45, h*=-1 => (-1, 29, 45) + DayTime dt(-2, -30, -15); + int h, m, s; + dt.getTime(h, m, s); + EXPECT_EQ(-1, h); + EXPECT_EQ(29, m); + EXPECT_EQ(45, s); +} diff --git a/unit_tests/test_core/types/test_declination.cpp b/unit_tests/test_core/types/test_declination.cpp new file mode 100644 index 00000000..eefa49e4 --- /dev/null +++ b/unit_tests/test_core/types/test_declination.cpp @@ -0,0 +1,64 @@ +#include +#include "core/types/Declination.hpp" + +using core::Declination; + +TEST(DeclinationTest, DefaultConstructor) +{ + Declination dec; + EXPECT_EQ(0, dec.getTotalSeconds()); +} + +TEST(DeclinationTest, DegreesConstructor) +{ + Declination dec(15.0f); + EXPECT_FLOAT_EQ(15.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, AddDegrees) +{ + Declination dec(10.0f); + dec.addDegrees(5); + EXPECT_FLOAT_EQ(15.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, SubtractDegrees) +{ + Declination dec(10.0f); + dec.addDegrees(-25); + EXPECT_FLOAT_EQ(-15.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, ClampUpper) +{ + Declination dec(179.0f); + dec.addDegrees(5); + EXPECT_FLOAT_EQ(180.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, ClampLower) +{ + Declination dec(-179.0f); + dec.addDegrees(-5); + EXPECT_FLOAT_EQ(-180.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, SetClamps) +{ + Declination dec; + dec.set(200, 0, 0); + EXPECT_FLOAT_EQ(180.0f, dec.getTotalDegrees()); +} + +TEST(DeclinationTest, CopyConstructor) +{ + Declination dec1(45.0f); + Declination dec2(dec1); + EXPECT_FLOAT_EQ(45.0f, dec2.getTotalDegrees()); +} + +TEST(DeclinationTest, GetTotalDegrees) +{ + Declination dec(30, 0, 0); + EXPECT_FLOAT_EQ(30.0f, dec.getTotalDegrees()); +} diff --git a/unit_tests/test_core/types/test_latitude.cpp b/unit_tests/test_core/types/test_latitude.cpp new file mode 100644 index 00000000..4bf3940a --- /dev/null +++ b/unit_tests/test_core/types/test_latitude.cpp @@ -0,0 +1,57 @@ +#include +#include "core/types/Latitude.hpp" + +using core::Latitude; + +TEST(LatitudeTest, DefaultConstructor) +{ + Latitude lat; + EXPECT_EQ(0, lat.getTotalSeconds()); +} + +TEST(LatitudeTest, DegreesConstructor) +{ + Latitude lat(45.0f); + EXPECT_FLOAT_EQ(45.0f, lat.getTotalHours()); +} + +TEST(LatitudeTest, ClampUpper) +{ + Latitude lat(89.0f); + lat.addHours(5.0f); + EXPECT_FLOAT_EQ(90.0f, lat.getTotalHours()); +} + +TEST(LatitudeTest, ClampLower) +{ + Latitude lat(-89.0f); + lat.addHours(-5.0f); + EXPECT_FLOAT_EQ(-90.0f, lat.getTotalHours()); +} + +TEST(LatitudeTest, NorthPole) +{ + Latitude lat(90, 0, 0); + EXPECT_EQ(90, lat.getHours()); + EXPECT_EQ(0, lat.getMinutes()); +} + +TEST(LatitudeTest, SouthPole) +{ + Latitude lat(-90, 0, 0); + EXPECT_EQ(-90, lat.getHours()); +} + +TEST(LatitudeTest, SetClamps) +{ + Latitude lat; + lat.set(95, 0, 0); + EXPECT_FLOAT_EQ(90.0f, lat.getTotalHours()); +} + +TEST(LatitudeTest, CopyConstructor) +{ + Latitude lat1(45.0f); + Latitude lat2(lat1); + EXPECT_FLOAT_EQ(45.0f, lat2.getTotalHours()); +} diff --git a/unit_tests/test_core/types/test_longitude.cpp b/unit_tests/test_core/types/test_longitude.cpp new file mode 100644 index 00000000..0312dee8 --- /dev/null +++ b/unit_tests/test_core/types/test_longitude.cpp @@ -0,0 +1,58 @@ +#include +#include "core/types/Longitude.hpp" + +using core::Longitude; + +TEST(LongitudeTest, DefaultConstructor) +{ + Longitude lon; + EXPECT_EQ(0, lon.getTotalSeconds()); +} + +TEST(LongitudeTest, DegreesConstructor) +{ + Longitude lon(100.0f); + EXPECT_FLOAT_EQ(100.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, WrapUpper) +{ + Longitude lon(179.0f); + lon.addHours(5.0f); + EXPECT_FLOAT_EQ(-176.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, WrapLower) +{ + Longitude lon(-179.0f); + lon.addHours(-5.0f); + EXPECT_FLOAT_EQ(176.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, NegativeWest) +{ + Longitude lon(-75.0f); + EXPECT_FLOAT_EQ(-75.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, PositiveEast) +{ + Longitude lon(120.0f); + EXPECT_FLOAT_EQ(120.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, WrapAt180) +{ + // Constructor doesn't call checkHours(); set() does. + Longitude lon(181, 0, 0); + EXPECT_FLOAT_EQ(181.0f, lon.getTotalHours()); + lon.set(181, 0, 0); + EXPECT_FLOAT_EQ(-179.0f, lon.getTotalHours()); +} + +TEST(LongitudeTest, CopyConstructor) +{ + Longitude lon1(50.0f); + Longitude lon2(lon1); + EXPECT_FLOAT_EQ(50.0f, lon2.getTotalHours()); +} diff --git a/unit_tests/test_core/types/test_right_ascension.cpp b/unit_tests/test_core/types/test_right_ascension.cpp new file mode 100644 index 00000000..da609ba7 --- /dev/null +++ b/unit_tests/test_core/types/test_right_ascension.cpp @@ -0,0 +1,77 @@ +#include +#include "core/types/RightAscension.hpp" + +using core::RightAscension; + +TEST(RightAscensionTest, DefaultConstructor) +{ + RightAscension ra; + EXPECT_EQ(0, ra.getTotalSeconds()); + EXPECT_EQ(0, ra.getHours()); +} + +TEST(RightAscensionTest, HMSConstructor) +{ + RightAscension ra(12, 34, 56); + EXPECT_EQ(12, ra.getHours()); + EXPECT_EQ(34, ra.getMinutes()); + EXPECT_EQ(56, ra.getSeconds()); +} + +TEST(RightAscensionTest, FloatConstructor) +{ + RightAscension ra(18.5f); // 18h 30m + EXPECT_EQ(18, ra.getHours()); + EXPECT_EQ(30, ra.getMinutes()); +} + +TEST(RightAscensionTest, WrapAt24Hours) +{ + RightAscension ra(23, 30, 0); + ra.addHours(2.0f); + EXPECT_EQ(1, ra.getHours()); + EXPECT_EQ(30, ra.getMinutes()); +} + +TEST(RightAscensionTest, WrapNegativeToPositive) +{ + RightAscension ra(1, 0, 0); + ra.addHours(-3.0f); + EXPECT_EQ(22, ra.getHours()); +} + +TEST(RightAscensionTest, ConstructorWraps) +{ + // Constructor calls checkHours() so 25h wraps to 1h + RightAscension ra(25, 0, 0); + EXPECT_EQ(1, ra.getHours()); +} + +TEST(RightAscensionTest, SetWraps) +{ + RightAscension ra; + ra.set(25, 30, 0); + EXPECT_EQ(1, ra.getHours()); + EXPECT_EQ(30, ra.getMinutes()); +} + +TEST(RightAscensionTest, NonNegative) +{ + RightAscension ra(-1, 0, 0); + EXPECT_EQ(23, ra.getHours()); +} + +TEST(RightAscensionTest, GetTotalHours) +{ + RightAscension ra(6, 30, 0); + EXPECT_FLOAT_EQ(6.5f, ra.getTotalHours()); +} + +TEST(RightAscensionTest, CopyConstructor) +{ + RightAscension ra1(10, 20, 30); + RightAscension ra2(ra1); + EXPECT_EQ(10, ra2.getHours()); + EXPECT_EQ(20, ra2.getMinutes()); + EXPECT_EQ(30, ra2.getSeconds()); +} From f622e06ee73f9f9d639a0105b8fac2a980a5a983 Mon Sep 17 00:00:00 2001 From: Andre Stefanov Date: Wed, 27 May 2026 08:30:14 +0200 Subject: [PATCH 2/2] feat: Implement ToString method for Latitude class --- src/Latitude.cpp | 7 +++++++ src/Latitude.hpp | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/Latitude.cpp b/src/Latitude.cpp index caaaee91..f1023f4e 100644 --- a/src/Latitude.cpp +++ b/src/Latitude.cpp @@ -20,6 +20,13 @@ Latitude::Latitude(float inDegrees) : core::Latitude(inDegrees) { } +char achBufLat[32]; + +const char *Latitude::ToString() const +{ + return core::DayTime::formatString(achBufLat, "{+}{d}:{m}:{s}"); +} + Latitude Latitude::ParseFromMeade(String const &s) { Latitude result(0.0); diff --git a/src/Latitude.hpp b/src/Latitude.hpp index 39e57bf8..c3ec1591 100644 --- a/src/Latitude.hpp +++ b/src/Latitude.hpp @@ -18,5 +18,8 @@ class Latitude : public core::Latitude Latitude(int h, int m, int s); Latitude(float inDegrees); + // Convert to a standard string (like +45:00:00) + virtual const char *ToString() const; + static Latitude ParseFromMeade(String const &s); };