From d982eaa14746b8ce03ca535e0ff41449770ae41e Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 17:22:58 -0400 Subject: [PATCH] refactor(#270): E4 band 3a -- fram, settings and rtc leave src.ino as units Three units, each taking its state with it, each including the previous: src/fram.{h,cpp} fram, framHeader, framAvailable; FRAMHeader, FRAMBatteryEntry, FRAMSettings; the 17 FRAM_* layout defines; framReadHeader, framWriteHeader, framFormat, initFRAM src/settings.{h,cpp} the nine user/display settings, tzPresets and TZPreset, the four timeout preset tables with their counts, settingsLoadedFromSD; framSettingsChecksum (static), saveSettingsToFRAM, loadSettingsFromFRAM, applyTimezone, mktimeUTC, formatTimeStr, loadSettings, saveSettings, factoryReset, findTimeoutIndex src/rtc.{h,cpp} rtc, rtcAvailable; initRTC, syncRTCFromSystemTime src/fc_config.h + FRAM_CS, TFT_BL (fram.cpp and factoryReset need them) Twelve of the 34 cut-set variables leave src.ino. framFlushToSD stays: it flushes the battery and weather buffers, two domains not yet published. Harness (scripts/extract_unit.py), five fixes found by this band: bare include names are wrapped; --header-lines is emitted before the externs that need its types; a multi-line table initialiser is carried whole; forward extern declarations of published variables are dropped; the comment that introduced a removed declaration is swept when only a blank follows it. Verified by scripts/verify_extraction.py, PASS: 584 -> 585 symbols (fram.cpp's static initialiser, 41 bytes; src.ino's shrank 34), 38 relocated, 2 linkage changes both declared, 26 size deltas net +22 all attributed, unexplained none. .flash.text +64 = deltas +22, initialiser +41, alignment +1; .flash.rodata +56 and .dram0.bss +16 with no data symbol changing size (packing across three new objects). Flash 1,856,026 -> 1,856,146. RAM 103,232 -> 103,248. src.ino 7,986 -> 7,483. Both toolchains build. 58 tests pass. Record: section 10 of docs/god-object-inventory.md. --- docs/god-object-inventory.md | 38 +++ scripts/extract_unit.py | 61 ++++- src/fc_config.h | 2 + src/fram.cpp | 69 +++++ src/fram.h | 80 ++++++ src/rtc.cpp | 64 +++++ src/rtc.h | 9 + src/settings.cpp | 285 +++++++++++++++++++ src/settings.h | 40 +++ src/src.ino | 513 +---------------------------------- 10 files changed, 646 insertions(+), 515 deletions(-) create mode 100644 src/fram.cpp create mode 100644 src/fram.h create mode 100644 src/rtc.cpp create mode 100644 src/rtc.h create mode 100644 src/settings.cpp create mode 100644 src/settings.h diff --git a/docs/god-object-inventory.md b/docs/god-object-inventory.md index 087e600..b979f59 100644 --- a/docs/god-object-inventory.md +++ b/docs/god-object-inventory.md @@ -283,6 +283,44 @@ python scripts/verify_extraction.py --before-nm nm-before.txt --before-graph gra --after-elf .pio/build/feather_s3/firmware.elf --moved --published [--resigned ] ``` +## 10. Band 3a — fram, settings and rtc; five things the harness learned + +**Band 3 was split first.** #264 said it would be, once band 2 had landed and the harness was proven. The relocation graph after band 2 gave the composition: five children, [#270](https://github.com/Strycher/Field_Compass/issues/270)–[#274](https://github.com/Strycher/Field_Compass/issues/274), grouped by dependency layer — storage/settings/time, the four sensors, the two data domains that consume them, display and navigation state, and the web server last. **Not `webServer` first, as #264's text says**: the graph shows `webServer` referenced only by the 23 handlers, `initWebServer` and `loop`, and `initWiFi` calling `initWebServer`. A web unit before the domains its handlers read would need those handlers declared in a header while they stay in `src.ino` — the reach-back the ruling forbids. The cut set says which variables must be owned; the graph says the order they can be. #264's acceptance is met when its last child lands. + +**What moved** — three units, in this order because each includes the previous: + +| unit | owns | functions | +|---|---|---| +| `fram` | `fram`, `framHeader`, `framAvailable`; `FRAMHeader`, `FRAMBatteryEntry`, `FRAMSettings`; the 17 `FRAM_*` layout defines | `framReadHeader`, `framWriteHeader`, `framFormat`, `initFRAM` | +| `settings` | the nine user and display settings, `tzPresets`/`TZPreset`, the four timeout preset tables and their counts, `settingsLoadedFromSD` | `framSettingsChecksum` (static), `saveSettingsToFRAM`, `loadSettingsFromFRAM`, `applyTimezone`, `mktimeUTC`, `formatTimeStr`, `loadSettings`, `saveSettings`, `factoryReset`, `findTimeoutIndex` | +| `rtc` | `rtc`, `rtcAvailable` | `initRTC`, `syncRTCFromSystemTime` | + +Twelve of the 34 remaining cut-set variables left `src.ino`. `FRAM_CS` and `TFT_BL` joined `fc_config.h`, which `fram.cpp` and `factoryReset` need. + +**Calls made under the grant, for review:** the timeout preset tables and `findTimeoutIndex` are *settings* (they define what `tftSleepMs` may be), not display, so they went with the unit that persists them; `framFlushToSD` stayed, because it flushes the battery and weather buffers — two domains not yet published — and the weather child (#272) decides its home. + +**Five things the harness learned**, each found by this band and fixed in `extract_unit.py`: bare include names are wrapped (`"x.h"` for our files, `` otherwise); a `--header-lines` block is emitted *before* the externs, since an `extern FRAMHeader framHeader;` needs the struct first; a table initialiser spanning lines (`tzPresets`, 16 of them) is carried whole; forward `extern` declarations of published variables are dropped like prototypes are (the timeout tables had six); and the comment line that introduced a removed declaration is swept when only a blank follows it (bands 2 and 3a had left eight). + +**Measured** — `scripts/verify_extraction.py`, PASS: + +``` +symbols 584 before -> 585 after, union of 8 objects + 38 relocated: fram 7, rtc 4, settings 27 (incl. the shared_count inline only settings.cpp instantiates) + 2 linkage changes, both declared: settingsLoadedFromSD b -> B, tzPresets r -> R + 1 added: _GLOBAL__sub_I_fram (41 bytes) -- fram.cpp constructs the FRAM object; + src.ino's initialiser shrank by 34 + removed: none +deltas 26 symbols, net +22: 8 call a moved function, 5 touch published state, + 2 are moved functions (loadSettings -4, findTimeoutIndex -2), 11 identical references + unexplained: none +sections .flash.text +64 = function deltas +22, the new initialiser +41, alignment +1 + .flash.rodata +56, .dram0.bss +16: no data symbol changed size; packing and string-literal + residue across three new objects (rodata/bss bytes: fram 195/73, + settings 1710/10, rtc 155/5) +Flash 1,856,026 -> 1,856,146 (+120) RAM 103,232 -> 103,248 (+16) +src.ino 7,986 -> 7,483 lines arduino-cli SUCCESS 58 tests pass +``` + --- ## Appendix — the full tables diff --git a/scripts/extract_unit.py b/scripts/extract_unit.py index 31cbe82..3c38517 100644 --- a/scripts/extract_unit.py +++ b/scripts/extract_unit.py @@ -132,7 +132,8 @@ def is_static(lines: list[str], start: int) -> bool: def find_var(lines: list[str], name: str) -> int: pat = re.compile(VAR_RE_TMPL.format(name=re.escape(name))) - return one([i for i, l in enumerate(lines) if pat.match(l)], f"variable {name}") + return one([i for i, l in enumerate(lines) + if pat.match(l) and not l.lstrip().startswith("extern ")], f"variable {name}") def extern_decl(line: str) -> str: @@ -185,6 +186,10 @@ def main() -> int: sys.exit(f"refusing: {x[4]} and {y[4]} overlap") var_lines = {n: find_var(lines, n) for n in mvars + pvars} + # a table initialiser (`static const TZPreset tzPresets[] = {` ... `};`) spans + # lines: carry the whole block, not just its first line (band 3a) + var_ends = {n: (block_end(lines, i) if "{" in lines[i] and "}" not in lines[i] else i) + for n, i in var_lines.items()} def_lines = {n: one([i for i, l in enumerate(lines) if re.match(r"^\s*#define\s+" + re.escape(n) + r"\b", l)], f"#define {n}") for n in defines} @@ -211,10 +216,25 @@ def main() -> int: if i != d and pat.match(l): protos[i] = (n, re.sub(r"\s*//.*$", "", l.rstrip("\r\n")).strip()) + # forward `extern` declarations of published variables are redundant once + # the header declares them (band 3a: the timeout tables had six) + fwd = {} # line index -> text + for n in pvars: + fpat = re.compile(r"^\s*extern\s+[^;=]*\b" + re.escape(n) + r"\s*(?:\[[^\]]*\])*\s*;") + for i, l in enumerate(lines): + if fpat.match(l): + fwd[i] = l.strip() + # ---- build outputs ------------------------------------------------------ + def inc_line(inc: str) -> str: + """A bare name becomes "name" if it is one of our files in src/, else .""" + if inc[0] in "<\"": + return f"#include {inc}" + nl + return (f'#include "{inc}"' if (SRC.parent / inc).exists() else f"#include <{inc}>") + nl + h = ["#pragma once" + nl, f"// {unit}.h -- extracted from src.ino by scripts/extract_unit.py (E4)." + nl] for inc in split(a.header_includes): - h.append(f"#include {inc}" + nl) + h.append(inc_line(inc)) if defines: h.append(nl) h.extend(lines[def_lines[n]] for n in defines) @@ -223,6 +243,11 @@ def main() -> int: s, e = decl_ranges[n] h.append(nl) h.extend(lines[s:e + 1]) + if hdr_range: + # verbatim block goes BEFORE the externs and prototypes: it is types and + # macros, and an extern of a struct type needs the struct first (band 3a) + h.append(nl) + h.extend(lines[hdr_range[0]:hdr_range[1] + 1]) if pvars: h.append(nl) h.extend(extern_decl(lines[var_lines[n]]) + nl for n in pvars) @@ -232,21 +257,19 @@ def main() -> int: for c, d, b, e, n, st in moves: if not st: h.append(existing.get(n, prototype(lines, d, b)) + nl) - if hdr_range: - h.append(nl) - h.extend(lines[hdr_range[0]:hdr_range[1] + 1]) cpp = [] if not a.header_only: cpp.append(f"// {unit}.cpp -- extracted from src.ino by scripts/extract_unit.py (E4)." + nl) cpp.append(f'#include "{unit}.h"' + nl) for inc in split(a.cpp_includes): - cpp.append(f"#include {inc}" + nl) + cpp.append(inc_line(inc)) if mvars or pvars: cpp.append(nl) for n in sorted(mvars + pvars, key=lambda x: var_lines[x]): l = lines[var_lines[n]] cpp.append(re.sub(r"^(\s*)static\s+", r"\1", l) if n in pvars else l) + cpp.extend(lines[var_lines[n] + 1:var_ends[n] + 1]) for c, d, b, e, n, st in moves: cpp.append(nl) cpp.extend(lines[c:e + 1]) @@ -268,6 +291,8 @@ def main() -> int: print(f" lines {hdr_range[0] + 1}-{hdr_range[1] + 1} -> header verbatim") for i in sorted(protos): print(f" reuse and drop prototype of {protos[i][0]} at line {i + 1}: {protos[i][1]}") + for i in sorted(fwd): + print(f" drop forward extern at line {i + 1}: {fwd[i]}") inc_idx = max(i for i, l in enumerate(lines[:200]) if l.startswith("#include")) print(f" insert #include \"{unit}.h\" after line {inc_idx + 1}") if a.dry_run: @@ -280,13 +305,35 @@ def main() -> int: remove.update(range(c, e + 1)) if e + 1 < len(lines) and lines[e + 1].strip() == "": remove.add(e + 1) - remove.update(var_lines.values()) + for n, i in var_lines.items(): + remove.update(range(i, var_ends[n] + 1)) remove.update(def_lines.values()) for s, e in decl_ranges.values(): remove.update(range(s, e + 1)) if hdr_range: remove.update(range(hdr_range[0], hdr_range[1] + 1)) remove.update(protos.keys()) + remove.update(fwd.keys()) + + # a `// ...` line that introduced a removed declaration is orphaned when the + # next kept line is blank (or the file ends): `// User settings (#70)` over + # six variables that all left. Sweep it, and any comment lines stacked + # above it. Scoped to removal sites; free-standing comments are untouched. + def is_comment(i: int) -> bool: + t = lines[i].rstrip("\r\n") + return t.lstrip().startswith("//") and not BANNER_RE.match(t) + + for i in sorted(remove): + if i == 0 or (i - 1) in remove or not is_comment(i - 1): + continue + j = i + while j < len(lines) and j in remove: + j += 1 + if j >= len(lines) or lines[j].strip() == "": + k = i - 1 + while k >= 0 and k not in remove and is_comment(k): + remove.add(k) + k -= 1 out = [l for i, l in enumerate(lines) if i not in remove] inc_idx2 = max(i for i, l in enumerate(out[:200]) if l.startswith("#include")) out.insert(inc_idx2 + 1, f'#include "{unit}.h"' + nl) diff --git a/src/fc_config.h b/src/fc_config.h index bd23eb0..b10f68c 100644 --- a/src/fc_config.h +++ b/src/fc_config.h @@ -5,3 +5,5 @@ #define SPI_SCK 36 // Default Feather SPI clock #define SPI_MOSI 35 // Default Feather SPI MOSI #define SPI_MISO 37 // Default Feather SPI MISO (unused for TFT) +#define FRAM_CS 15 // A3 -> FRAM CS +#define TFT_BL 8 // A5 -> LED pin on display module (PWM-dimmable backlight) diff --git a/src/fram.cpp b/src/fram.cpp new file mode 100644 index 0000000..248f90b --- /dev/null +++ b/src/fram.cpp @@ -0,0 +1,69 @@ +// fram.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "fram.h" +#include "fc_config.h" +#include "logging.h" + +Adafruit_FRAM_SPI fram = Adafruit_FRAM_SPI(FRAM_CS); // SPI FRAM 256KB +bool framAvailable = false; // SPI FRAM 256KB +FRAMHeader framHeader; + +// Read FRAM header into RAM (use sizeof to avoid overflowing the struct) +void framReadHeader() { + uint8_t buf[FRAM_HEADER_SIZE]; + for (int i = 0; i < FRAM_HEADER_SIZE; i++) { + buf[i] = fram.read8(FRAM_HEADER_ADDR + i); + } + memcpy(&framHeader, buf, sizeof(framHeader)); // only copy struct-sized bytes +} + +// Write the RAM header back to FRAM (zero-pad to fill full 64-byte region) +void framWriteHeader() { + uint8_t buf[FRAM_HEADER_SIZE]; + memset(buf, 0, FRAM_HEADER_SIZE); // zero-fill padding bytes + memcpy(buf, &framHeader, sizeof(framHeader)); // copy struct into buffer + for (int i = 0; i < FRAM_HEADER_SIZE; i++) { + fram.write8(FRAM_HEADER_ADDR + i, buf[i]); + } +} + +// Format FRAM with clean header (zeroed ring buffers) +void framFormat() { + logPrintln(" Formatting..."); + memset(&framHeader, 0, sizeof(framHeader)); + framHeader.magic = FRAM_MAGIC; + framHeader.version = FRAM_VERSION; + framHeader.battCapacity = FRAM_BATT_COUNT; + framHeader.wxCapacity = FRAM_WX_COUNT; + framWriteHeader(); + logPrintln(" Format complete"); +} + +void initFRAM() { + logPrint("Initializing FRAM... "); + // Safety check: struct must fit within FRAM header region + static_assert(sizeof(FRAMHeader) <= FRAM_HEADER_SIZE, "FRAMHeader exceeds FRAM_HEADER_SIZE"); + + if (!fram.begin()) { + logPrintln("NOT FOUND (check wiring)"); + return; + } + + // Verify FRAM by reading manufacturer/product IDs + uint8_t mfgId; + uint16_t prodId; + fram.getDeviceID(&mfgId, &prodId); + framAvailable = true; + logPrintf("OK (mfg:0x%02X prod:0x%04X, 256KB, hdr=%d/%d bytes)\n", + mfgId, prodId, sizeof(FRAMHeader), FRAM_HEADER_SIZE); + + // Read and validate FRAM header + framReadHeader(); + if (framHeader.magic != FRAM_MAGIC || framHeader.version != FRAM_VERSION) { + logPrintln(" Header invalid - formatting"); + framFormat(); + } else { + logPrintf(" Batt:%d/%d Wx:%d/%d pending\n", + framHeader.battCount, framHeader.battCapacity, + framHeader.wxCount, framHeader.wxCapacity); + } +} diff --git a/src/fram.h b/src/fram.h new file mode 100644 index 0000000..850fb82 --- /dev/null +++ b/src/fram.h @@ -0,0 +1,80 @@ +#pragma once +// fram.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +#define FRAM_MAGIC 0x4652414D // "FRAM" in ASCII +#define FRAM_VERSION 1 +#define FRAM_HEADER_ADDR 0x00000 +#define FRAM_HEADER_SIZE 64 +#define FRAM_BSEC_ADDR 0x00040 // 512 bytes for BSEC state blob +#define FRAM_BSEC_SIZE 512 +#define FRAM_BATT_ADDR 0x00240 // Battery ring buffer start +#define FRAM_BATT_ENTRY 20 // Bytes per battery entry +#define FRAM_BATT_COUNT 512 // Ring buffer capacity (~85 min at 10s) +#define FRAM_WX_ADDR 0x02A40 // Weather ring buffer start +#define FRAM_WX_ENTRY 24 // Bytes per weather entry +#define FRAM_WX_COUNT 300 // Ring buffer capacity (25 hrs at 5 min) +#define FRAM_FLUSH_INTERVAL 300000 // Flush to SD every 5 minutes (ms) +#define FRAM_SETTINGS_ADDR 0x046C0 // User settings backup (after weather ring) +#define FRAM_SETTINGS_SIZE 128 // Allocated block size +#define FRAM_SETTINGS_MAGIC 0x53544E47 // "STNG" in ASCII +#define FRAM_SETTINGS_VER 1 + +// FRAM ring buffer header (64 bytes, stored at FRAM_HEADER_ADDR) +struct FRAMHeader { + uint32_t magic; // FRAM_MAGIC validates initialized state + uint8_t version; // Schema version + uint8_t flags; // Bit 0: dirty (unwritten data), Bit 1: BSEC valid + uint16_t reserved1; + // Battery ring + uint16_t battHead; // Next write position (0 to FRAM_BATT_COUNT-1) + uint16_t battTail; // Next flush position + uint16_t battCount; // Entries pending flush + uint16_t battCapacity; // FRAM_BATT_COUNT + // Weather ring + uint16_t wxHead; + uint16_t wxTail; + uint16_t wxCount; + uint16_t wxCapacity; // FRAM_WX_COUNT + // BSEC metadata + uint32_t bsecTimestamp; // millis() when last saved + uint8_t bsecAccuracy; // IAQ accuracy at save time + uint8_t reserved2[27]; // Pad to 64 bytes total +}; + +struct FRAMBatteryEntry { + uint32_t timestamp; // millis() + float voltage; + float percent; + float rate; // Charge rate (%/hr) + uint16_t flags; // Reserved + uint16_t checksum; // XOR checksum +}; +// FRAMWeatherEntry: reuse existing WeatherReading struct (24 bytes, same layout) + +// FRAM settings backup struct (#118) — written as raw bytes to FRAM_SETTINGS_ADDR +struct FRAMSettings { + uint32_t magic; // FRAM_SETTINGS_MAGIC + uint8_t version; // FRAM_SETTINGS_VER + uint8_t use12Hour; + uint8_t useFahrenheit; + uint8_t useMetricUnits; + char posixTZ[48]; + char tzDisplayName[24]; + int8_t tzSelectedIndex; + uint8_t tftBrightness; + uint8_t _pad[2]; // Align to 4 bytes + uint32_t tftSleepMs; + uint32_t oledSleepMs; + uint32_t checksum; // XOR-32 of all preceding bytes +}; +static_assert(sizeof(FRAMSettings) <= FRAM_SETTINGS_SIZE, "FRAMSettings exceeds allocated block"); + +extern Adafruit_FRAM_SPI fram; +extern FRAMHeader framHeader; +extern bool framAvailable; + +void framReadHeader(); +void framWriteHeader(); +void framFormat(); +void initFRAM(); diff --git a/src/rtc.cpp b/src/rtc.cpp new file mode 100644 index 0000000..3d64eed --- /dev/null +++ b/src/rtc.cpp @@ -0,0 +1,64 @@ +// rtc.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "rtc.h" +#include "settings.h" +#include "logging.h" + +RTC_PCF8523 rtc; +bool rtcAvailable = false; // Adalogger RTC + +void initRTC() { + logPrint("Initializing RTC (PCF8523)... "); + + if (!rtc.begin()) { + logPrintln("NOT FOUND"); + return; + } + + rtcAvailable = true; + + // Check if RTC lost power and is running with invalid time + if (!rtc.initialized() || rtc.lostPower()) { + logPrintln("OK (needs time sync)"); + // Don't set a default time - wait for GPS or NTP to provide accurate time + return; + } + + // RTC has valid time - use it to set system time + DateTime now = rtc.now(); + struct tm timeinfo; + timeinfo.tm_year = now.year() - 1900; + timeinfo.tm_mon = now.month() - 1; + timeinfo.tm_mday = now.day(); + timeinfo.tm_hour = now.hour(); + timeinfo.tm_min = now.minute(); + timeinfo.tm_sec = now.second(); + + time_t t = mktimeUTC(&timeinfo); // RTC stores UTC — use UTC-aware mktime (#98 bugfix) + struct timeval tv = { .tv_sec = t, .tv_usec = 0 }; + settimeofday(&tv, NULL); + + logPrintf("OK (%04d-%02d-%02d %02d:%02d:%02d)\n", + now.year(), now.month(), now.day(), + now.hour(), now.minute(), now.second()); +} + +// Sync RTC from current system time (call after GPS or NTP sync) +void syncRTCFromSystemTime(const char* source) { + if (!rtcAvailable) return; + + struct tm timeinfo; + if (!getLocalTime(&timeinfo)) return; + + // Adjust for timezone - RTC stores UTC + time_t now; + time(&now); + struct tm* utc = gmtime(&now); + + rtc.adjust(DateTime(utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday, + utc->tm_hour, utc->tm_min, utc->tm_sec)); + + logPrintf("[RTC] Synced from %s: %04d-%02d-%02d %02d:%02d:%02d UTC\n", + source, + utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday, + utc->tm_hour, utc->tm_min, utc->tm_sec); +} diff --git a/src/rtc.h b/src/rtc.h new file mode 100644 index 0000000..2f9263f --- /dev/null +++ b/src/rtc.h @@ -0,0 +1,9 @@ +#pragma once +// rtc.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +extern RTC_PCF8523 rtc; +extern bool rtcAvailable; + +void initRTC(); +void syncRTCFromSystemTime(const char* source); diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..3c046c0 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,285 @@ +// settings.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "settings.h" +#include "fram.h" +#include "logging.h" +#include "fc_config.h" + +bool useMetricUnits = false; // false = imperial (ft/mi), true = metric (m/km) +bool use12Hour = true; // 12-hour format default (#98) +bool useFahrenheit = true; // Fahrenheit default (#98) +char posixTZ[48] = "EST5EDT,M3.2.0,M11.1.0"; // POSIX TZ string (#98) +char tzDisplayName[24] = "US Eastern"; // Friendly TZ name (#98) +int tzSelectedIndex = 0; // Index in tzPresets[] (#98) +uint8_t tftBrightness = 255; // PWM 25-255, step 25 +uint32_t tftSleepMs = 0; // 0 = never (default) +uint32_t oledSleepMs = 300000; // 5 minutes default +const TZPreset tzPresets[] = { + {"US Eastern", "EST5EDT,M3.2.0,M11.1.0", -5}, + {"US Central", "CST6CDT,M3.2.0,M11.1.0", -6}, + {"US Mountain", "MST7MDT,M3.2.0,M11.1.0", -7}, + {"US Pacific", "PST8PDT,M3.2.0,M11.1.0", -8}, + {"US Alaska", "AKST9AKDT,M3.2.0,M11.1.0", -9}, + {"US Hawaii", "HST10", -10}, + {"US Arizona", "MST7", -7}, + {"UTC", "UTC0", 0}, + {"UK / Ireland", "GMT0BST,M3.5.0/1,M10.5.0", 0}, + {"Central Europe", "CET-1CEST,M3.5.0,M10.5.0/3", 1}, + {"Eastern Europe", "EET-2EEST,M3.5.0/3,M10.5.0/4", 2}, + {"Japan / Korea", "JST-9", 9}, + {"Australia East", "AEST-10AEDT,M10.1.0,M4.1.0/3", 10}, + {"New Zealand", "NZST-12NZDT,M9.5.0,M4.1.0/3", 12}, +}; +bool settingsLoadedFromSD = false; // Deferred load flag (#118) +const uint32_t tftTimeoutPresets[] = {0, 60000, 120000, 300000, 600000, 900000, 1800000}; +const char* tftTimeoutLabels[] = {"Never", "1 min", "2 min", "5 min", "10 min", "15 min", "30 min"}; +const int TFT_TIMEOUT_COUNT = 7; +const uint32_t oledTimeoutPresets[] = {60000, 120000, 300000, 600000, 900000, 1800000}; +const char* oledTimeoutLabels[] = {"1 min", "2 min", "5 min", "10 min", "15 min", "30 min"}; +const int OLED_TIMEOUT_COUNT = 6; + +// XOR-32 checksum over raw bytes (excludes the checksum field itself) +static uint32_t framSettingsChecksum(const FRAMSettings& s) { + const uint8_t* p = (const uint8_t*)&s; + size_t len = offsetof(FRAMSettings, checksum); // everything before checksum + uint32_t ck = 0; + for (size_t i = 0; i < len; i++) ck ^= ((uint32_t)p[i]) << ((i & 3) * 8); + return ck; +} + +void saveSettingsToFRAM() { + if (!framAvailable) return; + + FRAMSettings s; + memset(&s, 0, sizeof(s)); + s.magic = FRAM_SETTINGS_MAGIC; + s.version = FRAM_SETTINGS_VER; + s.use12Hour = use12Hour ? 1 : 0; + s.useFahrenheit = useFahrenheit ? 1 : 0; + s.useMetricUnits = useMetricUnits ? 1 : 0; + strncpy(s.posixTZ, posixTZ, sizeof(s.posixTZ) - 1); + strncpy(s.tzDisplayName, tzDisplayName, sizeof(s.tzDisplayName) - 1); + s.tzSelectedIndex = (int8_t)tzSelectedIndex; + s.tftBrightness = tftBrightness; + s.tftSleepMs = tftSleepMs; + s.oledSleepMs = oledSleepMs; + s.checksum = framSettingsChecksum(s); + + const uint8_t* data = (const uint8_t*)&s; + for (size_t i = 0; i < sizeof(s); i++) { + fram.write8(FRAM_SETTINGS_ADDR + i, data[i]); + } + logPrintln("[SETTINGS] Saved to FRAM"); +} + +bool loadSettingsFromFRAM() { + if (!framAvailable) return false; + + FRAMSettings s; + uint8_t* data = (uint8_t*)&s; + for (size_t i = 0; i < sizeof(s); i++) { + data[i] = fram.read8(FRAM_SETTINGS_ADDR + i); + } + + if (s.magic != FRAM_SETTINGS_MAGIC || s.version != FRAM_SETTINGS_VER) return false; + if (s.checksum != framSettingsChecksum(s)) { + logPrintln("[SETTINGS] FRAM checksum mismatch, ignoring"); + return false; + } + + use12Hour = s.use12Hour; + useFahrenheit = s.useFahrenheit; + useMetricUnits = s.useMetricUnits; + strncpy(posixTZ, s.posixTZ, sizeof(posixTZ) - 1); + strncpy(tzDisplayName, s.tzDisplayName, sizeof(tzDisplayName) - 1); + tzSelectedIndex = s.tzSelectedIndex; + tftBrightness = constrain(s.tftBrightness, 25, 255); + tftSleepMs = s.tftSleepMs; + oledSleepMs = s.oledSleepMs; + + logPrintf("[SETTINGS] Loaded from FRAM: 12h=%d F=%d metric=%d tz=%s bright=%d\n", + use12Hour, useFahrenheit, useMetricUnits, tzDisplayName, tftBrightness); + return true; +} + +// Apply POSIX timezone to system (#98) +void applyTimezone() { + setenv("TZ", posixTZ, 1); + tzset(); + logPrintf("[SETTINGS] TZ applied: %s (%s)\n", tzDisplayName, posixTZ); +} + +// Convert struct tm (interpreted as UTC) to time_t, ignoring active POSIX TZ (#98 bugfix) +// mktime() always treats its argument as local time; this temporarily sets UTC +// so GPS/RTC UTC values are stored correctly as time_t. +time_t mktimeUTC(struct tm* tm) { + setenv("TZ", "UTC0", 1); + tzset(); + time_t t = mktime(tm); + setenv("TZ", posixTZ, 1); // Restore user's TZ + tzset(); + return t; +} + +// Format current time respecting 12/24h preference (#98) +// Returns chars written. buf must be >= 16 bytes. +int formatTimeStr(char* buf, int hour, int minute, int second, bool includeSeconds) { + if (use12Hour) { + const char* ampm = (hour >= 12) ? "PM" : "AM"; + int h12 = hour % 12; + if (h12 == 0) h12 = 12; + if (includeSeconds) + return sprintf(buf, "%d:%02d:%02d %s", h12, minute, second, ampm); + else + return sprintf(buf, "%d:%02d %s", h12, minute, ampm); + } else { + if (includeSeconds) + return sprintf(buf, "%02d:%02d:%02d", hour, minute, second); + else + return sprintf(buf, "%02d:%02d", hour, minute); + } +} + +// Load user settings — try SD first, fall back to FRAM (#98, #118) +void loadSettings() { + if (!sdHealth.available) { + // SD unavailable — try FRAM backup (#118) + if (loadSettingsFromFRAM()) { + settingsLoadedFromSD = true; // Treat FRAM load as success for deferred-load flag + applyTimezone(); + return; + } + logPrintln("[SETTINGS] SD + FRAM unavailable, using defaults"); + applyTimezone(); + return; + } + + File f = sdOpenSafe("/config/settings.txt", "r", true); // silent — normal on first boot + if (!f) { + // No SD file — try FRAM backup (#118) + if (loadSettingsFromFRAM()) { + settingsLoadedFromSD = true; + applyTimezone(); + return; + } + logPrintln("[SETTINGS] No settings file or FRAM backup, using defaults"); + applyTimezone(); + return; + } + + char line[80]; + while (f.available()) { + int idx = 0; + while (f.available() && idx < 79) { + char c = f.read(); + if (c == '\n' || c == '\r') break; + line[idx++] = c; + } + line[idx] = '\0'; + if (idx == 0) continue; + + char* eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + const char* key = line; + const char* val = eq + 1; + + if (strcmp(key, "use12Hour") == 0) use12Hour = atoi(val); + else if (strcmp(key, "useFahrenheit") == 0) useFahrenheit = atoi(val); + else if (strcmp(key, "useMetricUnits") == 0) useMetricUnits = atoi(val); + else if (strcmp(key, "posixTZ") == 0) strncpy(posixTZ, val, sizeof(posixTZ) - 1); + else if (strcmp(key, "tzName") == 0) strncpy(tzDisplayName, val, sizeof(tzDisplayName) - 1); + else if (strcmp(key, "tzIndex") == 0) tzSelectedIndex = atoi(val); + else if (strcmp(key, "tftBrightness") == 0) tftBrightness = constrain(atoi(val), 25, 255); + else if (strcmp(key, "tftSleepMs") == 0) tftSleepMs = strtoul(val, NULL, 10); + else if (strcmp(key, "oledSleepMs") == 0) oledSleepMs = strtoul(val, NULL, 10); + } + f.close(); + settingsLoadedFromSD = true; + applyTimezone(); + logPrintf("[SETTINGS] Loaded: 12h=%d F=%d metric=%d tz=%s bright=%d\n", + use12Hour, useFahrenheit, useMetricUnits, tzDisplayName, tftBrightness); +} + +// Save user settings to SD + FRAM (#98, #118) +// SD: atomic write via temp file (.tmp → rename) to prevent truncation loss. +// FRAM: instant backup — survives SD mount failures on next boot. +void saveSettings() { + // Always save to FRAM first — instant, no SD dependency (#118) + saveSettingsToFRAM(); + + if (!sdHealth.available) { + logPrintln("[SETTINGS] SD unavailable, attempting re-init before save..."); + trySDReInit(); // Try to recover SD before giving up (#118) + if (!sdHealth.available) { + logPrintln("[SETTINGS] SD still unavailable — saved to FRAM only"); + return; + } + } + if (!SD.exists("/config")) SD.mkdir("/config"); + + const char* tmpPath = "/config/settings.tmp"; + const char* finalPath = "/config/settings.txt"; + + File f = sdOpenSafe(tmpPath, "w", true); // silent — FRAM already has settings (#31 SD RED fix) + if (!f) { + logPrintln("[SETTINGS] Failed to open temp file — saved to FRAM only"); + return; + } + + f.printf("use12Hour=%d\n", use12Hour ? 1 : 0); + f.printf("useFahrenheit=%d\n", useFahrenheit ? 1 : 0); + f.printf("useMetricUnits=%d\n", useMetricUnits ? 1 : 0); + f.printf("posixTZ=%s\n", posixTZ); + f.printf("tzName=%s\n", tzDisplayName); + f.printf("tzIndex=%d\n", tzSelectedIndex); + f.printf("tftBrightness=%d\n", tftBrightness); + f.printf("tftSleepMs=%lu\n", tftSleepMs); + f.printf("oledSleepMs=%lu\n", oledSleepMs); + f.flush(); + f.close(); + + // Atomic swap: remove old, rename temp to final + if (SD.exists(finalPath)) SD.remove(finalPath); + SD.rename(tmpPath, finalPath); + logPrintln("[SETTINGS] Settings saved to SD + FRAM"); +} + +// Factory reset — delete settings file and restore compiled defaults (#104) +void factoryReset() { + // Delete stored settings from SD + if (sdHealth.available && SD.exists("/config/settings.txt")) { + SD.remove("/config/settings.txt"); + } + + // Clear FRAM settings backup (#118) — zero the magic so it won't be loaded + if (framAvailable) { + for (size_t i = 0; i < FRAM_SETTINGS_SIZE; i++) { + fram.write8(FRAM_SETTINGS_ADDR + i, 0); + } + logPrintln("[SETTINGS] FRAM settings cleared"); + } + + // Restore compiled defaults + useFahrenheit = true; + use12Hour = true; + useMetricUnits = false; + strncpy(posixTZ, "EST5EDT,M3.2.0,M11.1.0", sizeof(posixTZ) - 1); + strncpy(tzDisplayName, "US Eastern", sizeof(tzDisplayName) - 1); + tzSelectedIndex = 0; + tftBrightness = 255; + tftSleepMs = 0; + oledSleepMs = 300000; + + // Apply + applyTimezone(); + analogWrite(TFT_BL, tftBrightness); + + logPrintln("[SETTINGS] Factory reset — all settings restored to defaults"); +} + +// Helper: find index in timeout preset array matching a value +int findTimeoutIndex(const uint32_t presets[], int count, uint32_t value) { + for (int i = 0; i < count; i++) + if (presets[i] == value) return i; + return 0; // Default to first if not found +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..1be7185 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,40 @@ +#pragma once +// settings.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include +#include + +#define TZ_PRESET_COUNT 14 + +struct TZPreset { + const char* name; + const char* posix; + int8_t stdOffset; // For display: "UTC-5" +}; + +extern bool useMetricUnits; +extern bool use12Hour; +extern bool useFahrenheit; +extern char posixTZ[48]; +extern char tzDisplayName[24]; +extern int tzSelectedIndex; +extern uint8_t tftBrightness; +extern uint32_t tftSleepMs; +extern uint32_t oledSleepMs; +extern const TZPreset tzPresets[]; +extern bool settingsLoadedFromSD; +extern const uint32_t tftTimeoutPresets[]; +extern const char* tftTimeoutLabels[]; +extern const int TFT_TIMEOUT_COUNT; +extern const uint32_t oledTimeoutPresets[]; +extern const char* oledTimeoutLabels[]; +extern const int OLED_TIMEOUT_COUNT; + +void saveSettingsToFRAM(); +bool loadSettingsFromFRAM(); +void applyTimezone(); +time_t mktimeUTC(struct tm* tm); +int formatTimeStr(char* buf, int hour, int minute, int second, bool includeSeconds); +void loadSettings(); +void saveSettings(); +void factoryReset(); +int findTimeoutIndex(const uint32_t presets[], int count, uint32_t value); diff --git a/src/src.ino b/src/src.ino index 992c9ec..75bc0b2 100644 --- a/src/src.ino +++ b/src/src.ino @@ -62,6 +62,9 @@ const uint8_t bsec2_config[] = { #include "ui_widgets.h" #include "fc_config.h" #include "logging.h" +#include "fram.h" +#include "settings.h" +#include "rtc.h" // Set to 1 to enable LVGL test rendering (label in corner during boot). // Set to 0 for normal operation where sprite pipeline handles all rendering. @@ -111,33 +114,15 @@ const char* NTP_SERVER = "pool.ntp.org"; // TFT Display pins (ST7796U 3.5" IPS, SPI) — TFT_CS=18, TFT_DC=17, TFT_RST=16 are // defined in platformio.ini build_flags under USER_SETUP_LOADED=1, which tells // TFT_eSPI to skip its own header selection entirely. Not User_Setup.h (#184). -#define FRAM_CS 15 // A3 -> FRAM CS +// FRAM_CS, SD_CS, SPI_* and TFT_BL live in fc_config.h (E4). // Touch controller (FT6336U capacitive touch, I2C 0x38) #define CTP_INT 14 // A4 -> Touch interrupt (active-low, CHANGE — fires on touch + release) -// Backlight control (PWM dimming) -#define TFT_BL 8 // A5 -> LED pin on display module +// Backlight control (PWM dimming) -- TFT_BL pin is in fc_config.h #define TFT_BL_PWM 255 // Default brightness (0=off, 255=full) // FRAM Memory Map (256KB = 262,144 bytes, MB85RS2MTA) -#define FRAM_MAGIC 0x4652414D // "FRAM" in ASCII -#define FRAM_VERSION 1 -#define FRAM_HEADER_ADDR 0x00000 -#define FRAM_HEADER_SIZE 64 -#define FRAM_BSEC_ADDR 0x00040 // 512 bytes for BSEC state blob -#define FRAM_BSEC_SIZE 512 -#define FRAM_BATT_ADDR 0x00240 // Battery ring buffer start -#define FRAM_BATT_ENTRY 20 // Bytes per battery entry -#define FRAM_BATT_COUNT 512 // Ring buffer capacity (~85 min at 10s) -#define FRAM_WX_ADDR 0x02A40 // Weather ring buffer start -#define FRAM_WX_ENTRY 24 // Bytes per weather entry -#define FRAM_WX_COUNT 300 // Ring buffer capacity (25 hrs at 5 min) -#define FRAM_FLUSH_INTERVAL 300000 // Flush to SD every 5 minutes (ms) -#define FRAM_SETTINGS_ADDR 0x046C0 // User settings backup (after weather ring) -#define FRAM_SETTINGS_SIZE 128 // Allocated block size -#define FRAM_SETTINGS_MAGIC 0x53544E47 // "STNG" in ASCII -#define FRAM_SETTINGS_VER 1 // SPI pins (explicit definition for PSRAM variant compatibility) // Adafruit ESP32-S3 Feather default SPI pins @@ -262,18 +247,10 @@ Adafruit_SHT4x sht4 = Adafruit_SHT4x(); // SHT41 temp/humidity (#48) Adafruit_LSM6DSOX lsm; Adafruit_LIS3MDL lis; Adafruit_MAX17048 battery; -Adafruit_FRAM_SPI fram = Adafruit_FRAM_SPI(FRAM_CS); // SPI FRAM 256KB - -// RTC (Adalogger FeatherWing PCF8523) -RTC_PCF8523 rtc; // Web Server WebServer webServer(WEB_SERVER_PORT); -// Serial ring buffer for web streaming - -// Serial log to SD (#59) - // ============== Global State ============== // Current screen (0-3) @@ -305,7 +282,6 @@ bool shtAvailable = false; // SHT41 temp/humidity (#48) bool imuAvailable = false; bool magAvailable = false; bool batteryAvailable = false; -bool framAvailable = false; // SPI FRAM 256KB bool touchAvailable = false; // FT6336U capacitive touch // Magnetometer calibration (hard-iron offsets) @@ -318,61 +294,10 @@ float magCalMaxX, magCalMaxY, magCalMaxZ; #define MAG_CAL_DURATION_MS 15000 // 15 seconds #define MAG_MIN_MAGNITUDE 5.0 // µT — lowered for steel breadboard environment -// FRAM ring buffer header (64 bytes, stored at FRAM_HEADER_ADDR) -struct FRAMHeader { - uint32_t magic; // FRAM_MAGIC validates initialized state - uint8_t version; // Schema version - uint8_t flags; // Bit 0: dirty (unwritten data), Bit 1: BSEC valid - uint16_t reserved1; - // Battery ring - uint16_t battHead; // Next write position (0 to FRAM_BATT_COUNT-1) - uint16_t battTail; // Next flush position - uint16_t battCount; // Entries pending flush - uint16_t battCapacity; // FRAM_BATT_COUNT - // Weather ring - uint16_t wxHead; - uint16_t wxTail; - uint16_t wxCount; - uint16_t wxCapacity; // FRAM_WX_COUNT - // BSEC metadata - uint32_t bsecTimestamp; // millis() when last saved - uint8_t bsecAccuracy; // IAQ accuracy at save time - uint8_t reserved2[27]; // Pad to 64 bytes total -}; - -struct FRAMBatteryEntry { - uint32_t timestamp; // millis() - float voltage; - float percent; - float rate; // Charge rate (%/hr) - uint16_t flags; // Reserved - uint16_t checksum; // XOR checksum -}; -// FRAMWeatherEntry: reuse existing WeatherReading struct (24 bytes, same layout) - -// FRAM settings backup struct (#118) — written as raw bytes to FRAM_SETTINGS_ADDR -struct FRAMSettings { - uint32_t magic; // FRAM_SETTINGS_MAGIC - uint8_t version; // FRAM_SETTINGS_VER - uint8_t use12Hour; - uint8_t useFahrenheit; - uint8_t useMetricUnits; - char posixTZ[48]; - char tzDisplayName[24]; - int8_t tzSelectedIndex; - uint8_t tftBrightness; - uint8_t _pad[2]; // Align to 4 bytes - uint32_t tftSleepMs; - uint32_t oledSleepMs; - uint32_t checksum; // XOR-32 of all preceding bytes -}; -static_assert(sizeof(FRAMSettings) <= FRAM_SETTINGS_SIZE, "FRAMSettings exceeds allocated block"); // FRAM state (in RAM — synced from FRAM header on boot) -FRAMHeader framHeader; unsigned long lastFramFlush = 0; -bool rtcAvailable = false; // Adalogger RTC bool wifiConnected = false; bool ntpSynced = false; bool webServerStarted = false; @@ -488,44 +413,6 @@ unsigned long buttonCPressStart = 0; bool buttonCLongPressHandled = false; #define LONG_PRESS_MS 800 // 800ms for long press -// User settings (#70) -bool useMetricUnits = false; // false = imperial (ft/mi), true = metric (m/km) -bool use12Hour = true; // 12-hour format default (#98) -bool useFahrenheit = true; // Fahrenheit default (#98) -char posixTZ[48] = "EST5EDT,M3.2.0,M11.1.0"; // POSIX TZ string (#98) -char tzDisplayName[24] = "US Eastern"; // Friendly TZ name (#98) -int tzSelectedIndex = 0; // Index in tzPresets[] (#98) - -// Display settings (#91) -uint8_t tftBrightness = 255; // PWM 25-255, step 25 -uint32_t tftSleepMs = 0; // 0 = never (default) -uint32_t oledSleepMs = 300000; // 5 minutes default - -// Timezone presets with POSIX TZ strings (#98) -struct TZPreset { - const char* name; - const char* posix; - int8_t stdOffset; // For display: "UTC-5" -}; - -static const TZPreset tzPresets[] = { - {"US Eastern", "EST5EDT,M3.2.0,M11.1.0", -5}, - {"US Central", "CST6CDT,M3.2.0,M11.1.0", -6}, - {"US Mountain", "MST7MDT,M3.2.0,M11.1.0", -7}, - {"US Pacific", "PST8PDT,M3.2.0,M11.1.0", -8}, - {"US Alaska", "AKST9AKDT,M3.2.0,M11.1.0", -9}, - {"US Hawaii", "HST10", -10}, - {"US Arizona", "MST7", -7}, - {"UTC", "UTC0", 0}, - {"UK / Ireland", "GMT0BST,M3.5.0/1,M10.5.0", 0}, - {"Central Europe", "CET-1CEST,M3.5.0,M10.5.0/3", 1}, - {"Eastern Europe", "EET-2EEST,M3.5.0/3,M10.5.0/4", 2}, - {"Japan / Korea", "JST-9", 9}, - {"Australia East", "AEST-10AEDT,M10.1.0,M4.1.0/3", 10}, - {"New Zealand", "NZST-12NZDT,M9.5.0,M4.1.0/3", 12}, -}; -#define TZ_PRESET_COUNT 14 - // SHT41 data (#48) — primary source for temp/humidity. Named (#260). struct ShtData { float temperature = 0; // Temperature (C) — ±0.2°C accuracy @@ -571,17 +458,8 @@ struct WeatherTrend { -static bool settingsLoadedFromSD = false; // Deferred load flag (#118) -// Forward declarations for SD health functions (defined after initSD) - -// Forward declarations for FRAM settings backup (#118) -bool loadSettingsFromFRAM(); -void saveSettingsToFRAM(); - -// Forward declarations for serial log to SD (#59) - // Forward declarations the Arduino preprocessor used to generate (#261). // These 13 are called before they are defined and had no explicit prototype -- // the exact set that breaks the moment their code leaves the .ino, where @@ -596,7 +474,6 @@ const char* getTrendArrow(); bool isBatteryConnected(); bool loadBsecFromFRAM(); bool loadBsecState(); -time_t mktimeUTC(struct tm* tm); void readBME688(); void readIMU(); void readSHT41(); @@ -3181,15 +3058,6 @@ static void cfgOKCb(lv_event_t* e) { settingsSubScreen = 0; } -// Forward declarations for display callbacks (#112) -extern const uint32_t tftTimeoutPresets[]; -extern const char* tftTimeoutLabels[]; -extern const int TFT_TIMEOUT_COUNT; -extern const uint32_t oledTimeoutPresets[]; -extern const char* oledTimeoutLabels[]; -extern const int OLED_TIMEOUT_COUNT; -int findTimeoutIndex(const uint32_t presets[], int count, uint32_t value); - // Display sub-screen callbacks (#112) static void dispBrightnessChangedCb(lv_event_t* e) { lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); @@ -4371,67 +4239,6 @@ void initSHT41() { } } -// Read FRAM header into RAM (use sizeof to avoid overflowing the struct) -void framReadHeader() { - uint8_t buf[FRAM_HEADER_SIZE]; - for (int i = 0; i < FRAM_HEADER_SIZE; i++) { - buf[i] = fram.read8(FRAM_HEADER_ADDR + i); - } - memcpy(&framHeader, buf, sizeof(framHeader)); // only copy struct-sized bytes -} - -// Write the RAM header back to FRAM (zero-pad to fill full 64-byte region) -void framWriteHeader() { - uint8_t buf[FRAM_HEADER_SIZE]; - memset(buf, 0, FRAM_HEADER_SIZE); // zero-fill padding bytes - memcpy(buf, &framHeader, sizeof(framHeader)); // copy struct into buffer - for (int i = 0; i < FRAM_HEADER_SIZE; i++) { - fram.write8(FRAM_HEADER_ADDR + i, buf[i]); - } -} - -// Format FRAM with clean header (zeroed ring buffers) -void framFormat() { - logPrintln(" Formatting..."); - memset(&framHeader, 0, sizeof(framHeader)); - framHeader.magic = FRAM_MAGIC; - framHeader.version = FRAM_VERSION; - framHeader.battCapacity = FRAM_BATT_COUNT; - framHeader.wxCapacity = FRAM_WX_COUNT; - framWriteHeader(); - logPrintln(" Format complete"); -} - -void initFRAM() { - logPrint("Initializing FRAM... "); - // Safety check: struct must fit within FRAM header region - static_assert(sizeof(FRAMHeader) <= FRAM_HEADER_SIZE, "FRAMHeader exceeds FRAM_HEADER_SIZE"); - - if (!fram.begin()) { - logPrintln("NOT FOUND (check wiring)"); - return; - } - - // Verify FRAM by reading manufacturer/product IDs - uint8_t mfgId; - uint16_t prodId; - fram.getDeviceID(&mfgId, &prodId); - framAvailable = true; - logPrintf("OK (mfg:0x%02X prod:0x%04X, 256KB, hdr=%d/%d bytes)\n", - mfgId, prodId, sizeof(FRAMHeader), FRAM_HEADER_SIZE); - - // Read and validate FRAM header - framReadHeader(); - if (framHeader.magic != FRAM_MAGIC || framHeader.version != FRAM_VERSION) { - logPrintln(" Header invalid - formatting"); - framFormat(); - } else { - logPrintf(" Batt:%d/%d Wx:%d/%d pending\n", - framHeader.battCount, framHeader.battCapacity, - framHeader.wxCount, framHeader.wxCapacity); - } -} - void initIMU() { logPrint("Initializing LSM6DSOX... "); @@ -4623,63 +4430,6 @@ void initSD() { } } -void initRTC() { - logPrint("Initializing RTC (PCF8523)... "); - - if (!rtc.begin()) { - logPrintln("NOT FOUND"); - return; - } - - rtcAvailable = true; - - // Check if RTC lost power and is running with invalid time - if (!rtc.initialized() || rtc.lostPower()) { - logPrintln("OK (needs time sync)"); - // Don't set a default time - wait for GPS or NTP to provide accurate time - return; - } - - // RTC has valid time - use it to set system time - DateTime now = rtc.now(); - struct tm timeinfo; - timeinfo.tm_year = now.year() - 1900; - timeinfo.tm_mon = now.month() - 1; - timeinfo.tm_mday = now.day(); - timeinfo.tm_hour = now.hour(); - timeinfo.tm_min = now.minute(); - timeinfo.tm_sec = now.second(); - - time_t t = mktimeUTC(&timeinfo); // RTC stores UTC — use UTC-aware mktime (#98 bugfix) - struct timeval tv = { .tv_sec = t, .tv_usec = 0 }; - settimeofday(&tv, NULL); - - logPrintf("OK (%04d-%02d-%02d %02d:%02d:%02d)\n", - now.year(), now.month(), now.day(), - now.hour(), now.minute(), now.second()); -} - -// Sync RTC from current system time (call after GPS or NTP sync) -void syncRTCFromSystemTime(const char* source) { - if (!rtcAvailable) return; - - struct tm timeinfo; - if (!getLocalTime(&timeinfo)) return; - - // Adjust for timezone - RTC stores UTC - time_t now; - time(&now); - struct tm* utc = gmtime(&now); - - rtc.adjust(DateTime(utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday, - utc->tm_hour, utc->tm_min, utc->tm_sec)); - - logPrintf("[RTC] Synced from %s: %04d-%02d-%02d %02d:%02d:%02d UTC\n", - source, - utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday, - utc->tm_hour, utc->tm_min, utc->tm_sec); -} - // ============== SD Card Health Functions ============== // ============== BSEC State Persistence ============== @@ -4863,70 +4613,6 @@ void framFlushToSD() { // ============== FRAM Settings Backup (#118) ============== -// XOR-32 checksum over raw bytes (excludes the checksum field itself) -static uint32_t framSettingsChecksum(const FRAMSettings& s) { - const uint8_t* p = (const uint8_t*)&s; - size_t len = offsetof(FRAMSettings, checksum); // everything before checksum - uint32_t ck = 0; - for (size_t i = 0; i < len; i++) ck ^= ((uint32_t)p[i]) << ((i & 3) * 8); - return ck; -} - -void saveSettingsToFRAM() { - if (!framAvailable) return; - - FRAMSettings s; - memset(&s, 0, sizeof(s)); - s.magic = FRAM_SETTINGS_MAGIC; - s.version = FRAM_SETTINGS_VER; - s.use12Hour = use12Hour ? 1 : 0; - s.useFahrenheit = useFahrenheit ? 1 : 0; - s.useMetricUnits = useMetricUnits ? 1 : 0; - strncpy(s.posixTZ, posixTZ, sizeof(s.posixTZ) - 1); - strncpy(s.tzDisplayName, tzDisplayName, sizeof(s.tzDisplayName) - 1); - s.tzSelectedIndex = (int8_t)tzSelectedIndex; - s.tftBrightness = tftBrightness; - s.tftSleepMs = tftSleepMs; - s.oledSleepMs = oledSleepMs; - s.checksum = framSettingsChecksum(s); - - const uint8_t* data = (const uint8_t*)&s; - for (size_t i = 0; i < sizeof(s); i++) { - fram.write8(FRAM_SETTINGS_ADDR + i, data[i]); - } - logPrintln("[SETTINGS] Saved to FRAM"); -} - -bool loadSettingsFromFRAM() { - if (!framAvailable) return false; - - FRAMSettings s; - uint8_t* data = (uint8_t*)&s; - for (size_t i = 0; i < sizeof(s); i++) { - data[i] = fram.read8(FRAM_SETTINGS_ADDR + i); - } - - if (s.magic != FRAM_SETTINGS_MAGIC || s.version != FRAM_SETTINGS_VER) return false; - if (s.checksum != framSettingsChecksum(s)) { - logPrintln("[SETTINGS] FRAM checksum mismatch, ignoring"); - return false; - } - - use12Hour = s.use12Hour; - useFahrenheit = s.useFahrenheit; - useMetricUnits = s.useMetricUnits; - strncpy(posixTZ, s.posixTZ, sizeof(posixTZ) - 1); - strncpy(tzDisplayName, s.tzDisplayName, sizeof(tzDisplayName) - 1); - tzSelectedIndex = s.tzSelectedIndex; - tftBrightness = constrain(s.tftBrightness, 25, 255); - tftSleepMs = s.tftSleepMs; - oledSleepMs = s.oledSleepMs; - - logPrintf("[SETTINGS] Loaded from FRAM: 12h=%d F=%d metric=%d tz=%s bright=%d\n", - use12Hour, useFahrenheit, useMetricUnits, tzDisplayName, tftBrightness); - return true; -} - // ============== Geocache Found Status Persistence (#70) ============== #define GEOCACHE_FOUND_FILE "/geocache_found.csv" @@ -5398,182 +5084,6 @@ void saveMagCal() { // ─── Settings Persistence (#98) ───────────────────────────────────────────── -// Apply POSIX timezone to system (#98) -void applyTimezone() { - setenv("TZ", posixTZ, 1); - tzset(); - logPrintf("[SETTINGS] TZ applied: %s (%s)\n", tzDisplayName, posixTZ); -} - -// Convert struct tm (interpreted as UTC) to time_t, ignoring active POSIX TZ (#98 bugfix) -// mktime() always treats its argument as local time; this temporarily sets UTC -// so GPS/RTC UTC values are stored correctly as time_t. -time_t mktimeUTC(struct tm* tm) { - setenv("TZ", "UTC0", 1); - tzset(); - time_t t = mktime(tm); - setenv("TZ", posixTZ, 1); // Restore user's TZ - tzset(); - return t; -} - -// Format current time respecting 12/24h preference (#98) -// Returns chars written. buf must be >= 16 bytes. -int formatTimeStr(char* buf, int hour, int minute, int second, bool includeSeconds) { - if (use12Hour) { - const char* ampm = (hour >= 12) ? "PM" : "AM"; - int h12 = hour % 12; - if (h12 == 0) h12 = 12; - if (includeSeconds) - return sprintf(buf, "%d:%02d:%02d %s", h12, minute, second, ampm); - else - return sprintf(buf, "%d:%02d %s", h12, minute, ampm); - } else { - if (includeSeconds) - return sprintf(buf, "%02d:%02d:%02d", hour, minute, second); - else - return sprintf(buf, "%02d:%02d", hour, minute); - } -} - -// Load user settings — try SD first, fall back to FRAM (#98, #118) -void loadSettings() { - if (!sdHealth.available) { - // SD unavailable — try FRAM backup (#118) - if (loadSettingsFromFRAM()) { - settingsLoadedFromSD = true; // Treat FRAM load as success for deferred-load flag - applyTimezone(); - return; - } - logPrintln("[SETTINGS] SD + FRAM unavailable, using defaults"); - applyTimezone(); - return; - } - - File f = sdOpenSafe("/config/settings.txt", "r", true); // silent — normal on first boot - if (!f) { - // No SD file — try FRAM backup (#118) - if (loadSettingsFromFRAM()) { - settingsLoadedFromSD = true; - applyTimezone(); - return; - } - logPrintln("[SETTINGS] No settings file or FRAM backup, using defaults"); - applyTimezone(); - return; - } - - char line[80]; - while (f.available()) { - int idx = 0; - while (f.available() && idx < 79) { - char c = f.read(); - if (c == '\n' || c == '\r') break; - line[idx++] = c; - } - line[idx] = '\0'; - if (idx == 0) continue; - - char* eq = strchr(line, '='); - if (!eq) continue; - *eq = '\0'; - const char* key = line; - const char* val = eq + 1; - - if (strcmp(key, "use12Hour") == 0) use12Hour = atoi(val); - else if (strcmp(key, "useFahrenheit") == 0) useFahrenheit = atoi(val); - else if (strcmp(key, "useMetricUnits") == 0) useMetricUnits = atoi(val); - else if (strcmp(key, "posixTZ") == 0) strncpy(posixTZ, val, sizeof(posixTZ) - 1); - else if (strcmp(key, "tzName") == 0) strncpy(tzDisplayName, val, sizeof(tzDisplayName) - 1); - else if (strcmp(key, "tzIndex") == 0) tzSelectedIndex = atoi(val); - else if (strcmp(key, "tftBrightness") == 0) tftBrightness = constrain(atoi(val), 25, 255); - else if (strcmp(key, "tftSleepMs") == 0) tftSleepMs = strtoul(val, NULL, 10); - else if (strcmp(key, "oledSleepMs") == 0) oledSleepMs = strtoul(val, NULL, 10); - } - f.close(); - settingsLoadedFromSD = true; - applyTimezone(); - logPrintf("[SETTINGS] Loaded: 12h=%d F=%d metric=%d tz=%s bright=%d\n", - use12Hour, useFahrenheit, useMetricUnits, tzDisplayName, tftBrightness); -} - -// Save user settings to SD + FRAM (#98, #118) -// SD: atomic write via temp file (.tmp → rename) to prevent truncation loss. -// FRAM: instant backup — survives SD mount failures on next boot. -void saveSettings() { - // Always save to FRAM first — instant, no SD dependency (#118) - saveSettingsToFRAM(); - - if (!sdHealth.available) { - logPrintln("[SETTINGS] SD unavailable, attempting re-init before save..."); - trySDReInit(); // Try to recover SD before giving up (#118) - if (!sdHealth.available) { - logPrintln("[SETTINGS] SD still unavailable — saved to FRAM only"); - return; - } - } - if (!SD.exists("/config")) SD.mkdir("/config"); - - const char* tmpPath = "/config/settings.tmp"; - const char* finalPath = "/config/settings.txt"; - - File f = sdOpenSafe(tmpPath, "w", true); // silent — FRAM already has settings (#31 SD RED fix) - if (!f) { - logPrintln("[SETTINGS] Failed to open temp file — saved to FRAM only"); - return; - } - - f.printf("use12Hour=%d\n", use12Hour ? 1 : 0); - f.printf("useFahrenheit=%d\n", useFahrenheit ? 1 : 0); - f.printf("useMetricUnits=%d\n", useMetricUnits ? 1 : 0); - f.printf("posixTZ=%s\n", posixTZ); - f.printf("tzName=%s\n", tzDisplayName); - f.printf("tzIndex=%d\n", tzSelectedIndex); - f.printf("tftBrightness=%d\n", tftBrightness); - f.printf("tftSleepMs=%lu\n", tftSleepMs); - f.printf("oledSleepMs=%lu\n", oledSleepMs); - f.flush(); - f.close(); - - // Atomic swap: remove old, rename temp to final - if (SD.exists(finalPath)) SD.remove(finalPath); - SD.rename(tmpPath, finalPath); - logPrintln("[SETTINGS] Settings saved to SD + FRAM"); -} - -// Factory reset — delete settings file and restore compiled defaults (#104) -void factoryReset() { - // Delete stored settings from SD - if (sdHealth.available && SD.exists("/config/settings.txt")) { - SD.remove("/config/settings.txt"); - } - - // Clear FRAM settings backup (#118) — zero the magic so it won't be loaded - if (framAvailable) { - for (size_t i = 0; i < FRAM_SETTINGS_SIZE; i++) { - fram.write8(FRAM_SETTINGS_ADDR + i, 0); - } - logPrintln("[SETTINGS] FRAM settings cleared"); - } - - // Restore compiled defaults - useFahrenheit = true; - use12Hour = true; - useMetricUnits = false; - strncpy(posixTZ, "EST5EDT,M3.2.0,M11.1.0", sizeof(posixTZ) - 1); - strncpy(tzDisplayName, "US Eastern", sizeof(tzDisplayName) - 1); - tzSelectedIndex = 0; - tftBrightness = 255; - tftSleepMs = 0; - oledSleepMs = 300000; - - // Apply - applyTimezone(); - analogWrite(TFT_BL, tftBrightness); - - logPrintln("[SETTINGS] Factory reset — all settings restored to defaults"); -} - // Load weather history from SD card on boot void loadWeatherHistory() { if (!sdHealth.available) return; @@ -7274,20 +6784,7 @@ void handleGeocacheButtons(bool buttonA, bool buttonB) { } // Timeout presets for Display settings (#91) -const uint32_t tftTimeoutPresets[] = {0, 60000, 120000, 300000, 600000, 900000, 1800000}; -const char* tftTimeoutLabels[] = {"Never", "1 min", "2 min", "5 min", "10 min", "15 min", "30 min"}; -const int TFT_TIMEOUT_COUNT = 7; - -const uint32_t oledTimeoutPresets[] = {60000, 120000, 300000, 600000, 900000, 1800000}; -const char* oledTimeoutLabels[] = {"1 min", "2 min", "5 min", "10 min", "15 min", "30 min"}; -const int OLED_TIMEOUT_COUNT = 6; -// Helper: find index in timeout preset array matching a value -int findTimeoutIndex(const uint32_t presets[], int count, uint32_t value) { - for (int i = 0; i < count; i++) - if (presets[i] == value) return i; - return 0; // Default to first if not found -} // handleTap removed — gear icon now uses LVGL gearIconClickCb (#113)