diff --git a/exercises/12_FiveTalk/src/main.cpp b/exercises/12_FiveTalk/src/main.cpp index 91db251..3b1d749 100644 --- a/exercises/12_FiveTalk/src/main.cpp +++ b/exercises/12_FiveTalk/src/main.cpp @@ -120,14 +120,21 @@ struct DateTime { struct GpsState { bool sawAnySentence = false; bool hasValidUtc = false; + bool hasValidPosition = false; + bool hasValidAltitude = false; uint8_t satsUsed = 0; uint8_t satsInView = 0; uint32_t lastUtcMs = 0; DateTime utc{}; + double latitudeDeg = 0.0; + double longitudeDeg = 0.0; + float altitudeM = 0.0f; }; static GpsState g_gps; +static void parsePayloadCoords(const char* msg, char* latOut, size_t latLen, char* lonOut, size_t lonLen, char* altOut, size_t altLen); + enum class AppPhase : uint8_t { WAIT_SD = 0, WAIT_DISCIPLINE, @@ -320,11 +327,46 @@ static bool parseUInt2(const char* s, uint8_t& out) { return true; } +static bool parseNmeaCoordToDecimal(const char* raw, const char* hemi, bool isLat, double& outDeg) { + if (!raw || !hemi || raw[0] == '\0' || hemi[0] == '\0') return false; + + int degDigits = isLat ? 2 : 3; + size_t n = strlen(raw); + if (n <= (size_t)degDigits + 2) return false; + + for (int i = 0; i < degDigits; ++i) { + if (!isdigit((unsigned char)raw[i])) return false; + } + + char degBuf[4] = {0}; + memcpy(degBuf, raw, degDigits); + int deg = atoi(degBuf); + + const char* minPtr = raw + degDigits; + double minutes = atof(minPtr); + if (minutes < 0.0 || minutes >= 60.0) return false; + + double dec = (double)deg + (minutes / 60.0); + char h = (char)toupper((unsigned char)hemi[0]); + if (h == 'S' || h == 'W') { + dec = -dec; + } else if (h != 'N' && h != 'E') { + return false; + } + + outDeg = dec; + return true; +} + static void parseRmc(char* fields[], int count) { if (count <= 9) return; const char* utc = fields[1]; const char* status = fields[2]; + const char* latRaw = (count > 3) ? fields[3] : nullptr; + const char* latHem = (count > 4) ? fields[4] : nullptr; + const char* lonRaw = (count > 5) ? fields[5] : nullptr; + const char* lonHem = (count > 6) ? fields[6] : nullptr; const char* date = fields[9]; if (!status || status[0] != 'A') return; @@ -343,12 +385,39 @@ static void parseRmc(char* fields[], int count) { g_gps.utc.year = (uint16_t)(2000U + yy); g_gps.hasValidUtc = true; g_gps.lastUtcMs = millis(); + + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } } static void parseGga(char* fields[], int count) { if (count <= 7) return; + const char* latRaw = (count > 2) ? fields[2] : nullptr; + const char* latHem = (count > 3) ? fields[3] : nullptr; + const char* lonRaw = (count > 4) ? fields[4] : nullptr; + const char* lonHem = (count > 5) ? fields[5] : nullptr; int sats = atoi(fields[7]); if (sats >= 0 && sats <= 255) g_gps.satsUsed = (uint8_t)sats; + + if (count > 9 && fields[9] && fields[9][0] != '\0') { + g_gps.altitudeM = (float)atof(fields[9]); + g_gps.hasValidAltitude = true; + } + + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } } static void parseGsv(char* fields[], int count) { @@ -632,6 +701,20 @@ static bool openSessionLogs() { return true; } +static void gpsFieldStrings(char* latOut, size_t latLen, char* lonOut, size_t lonLen, char* altOut, size_t altLen) { + if (latOut && latLen > 0) latOut[0] = '\0'; + if (lonOut && lonLen > 0) lonOut[0] = '\0'; + if (altOut && altLen > 0) altOut[0] = '\0'; + + if (g_gps.hasValidPosition) { + if (latOut && latLen > 0) snprintf(latOut, latLen, "%.6f", g_gps.latitudeDeg); + if (lonOut && lonLen > 0) snprintf(lonOut, lonLen, "%.6f", g_gps.longitudeDeg); + } + if (g_gps.hasValidAltitude) { + if (altOut && altLen > 0) snprintf(altOut, altLen, "%.2f", g_gps.altitudeM); + } +} + static void writeSentLog(int64_t epoch, const DateTime& dt) { if (!g_sessionReady || !g_sentFile) return; @@ -642,11 +725,17 @@ static void writeSentLog(int64_t epoch, const DateTime& dt) { char human[32]; formatUtcHuman(dt, human, sizeof(human)); - g_sentFile.printf("epoch=%lld\tutc=%s\tunit=%s\tmsg=%s\ttx_count=%lu\tbatt_present=%u\tbatt_v=%.3f\n", + char lat[24], lon[24], alt[24]; + gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + + g_sentFile.printf("epoch=%lld\tutc=%s\tunit=%s\tmsg=%s\tlat=%s\tlon=%s\talt_m=%s\ttx_count=%lu\tbatt_present=%u\tbatt_v=%.3f\n", (long long)epoch, human, NODE_SHORT, NODE_SHORT, + lat, + lon, + alt, (unsigned long)g_txCount, battPresent ? 1U : 0U, battV); @@ -663,11 +752,17 @@ static void writeRecvLog(int64_t epoch, const DateTime& dt, const char* msg, flo char human[32]; formatUtcHuman(dt, human, sizeof(human)); - g_recvFile.printf("epoch=%lld\tutc=%s\tunit=%s\trx_msg=%s\trssi=%.1f\tsnr=%.1f\tbatt_present=%u\tbatt_v=%.3f\n", + char lat[24], lon[24], alt[24]; + parsePayloadCoords(msg, lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + + g_recvFile.printf("epoch=%lld\tutc=%s\tunit=%s\trx_msg=%s\trx_lat=%s\trx_lon=%s\trx_alt_m=%s\trssi=%.1f\tsnr=%.1f\tbatt_present=%u\tbatt_v=%.3f\n", (long long)epoch, human, NODE_SHORT, msg ? msg : "", + lat, + lon, + alt, rssi, snr, battPresent ? 1U : 0U, @@ -675,6 +770,41 @@ static void writeRecvLog(int64_t epoch, const DateTime& dt, const char* msg, flo g_recvFile.flush(); } +static void buildTxPayload(char* out, size_t outLen) { + if (!out || outLen == 0) return; + out[0] = '\0'; + + char lat[24], lon[24], alt[24]; + gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + snprintf(out, outLen, "%s,%s,%s,%s", NODE_SHORT, lat, lon, alt); +} + +static void parsePayloadCoords(const char* msg, char* latOut, size_t latLen, char* lonOut, size_t lonLen, char* altOut, size_t altLen) { + if (latOut && latLen > 0) latOut[0] = '\0'; + if (lonOut && lonLen > 0) lonOut[0] = '\0'; + if (altOut && altLen > 0) altOut[0] = '\0'; + if (!msg || msg[0] == '\0') return; + + char buf[128]; + size_t n = strlen(msg); + if (n >= sizeof(buf)) n = sizeof(buf) - 1; + memcpy(buf, msg, n); + buf[n] = '\0'; + + char* saveptr = nullptr; + char* token = strtok_r(buf, ",", &saveptr); // unit label + (void)token; + + token = strtok_r(nullptr, ",", &saveptr); // lat + if (token && latOut && latLen > 0) snprintf(latOut, latLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // lon + if (token && lonOut && lonLen > 0) snprintf(lonOut, lonLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // alt + if (token && altOut && altLen > 0) snprintf(altOut, altLen, "%s", token); +} + static void onLoRaDio1Rise() { g_rxFlag = true; } @@ -725,11 +855,13 @@ static void runTxScheduler() { g_rxFlag = false; g_radio.clearDio1Action(); - int tx = g_radio.transmit(NODE_SHORT); + char payload[96]; + buildTxPayload(payload, sizeof(payload)); + int tx = g_radio.transmit(payload); if (tx == RADIOLIB_ERR_NONE) { g_txCount++; writeSentLog(epoch, now); - logf("TX %s count=%lu", NODE_SHORT, (unsigned long)g_txCount); + logf("TX %s count=%lu payload=%s", NODE_SHORT, (unsigned long)g_txCount, payload); } else { logf("TX failed code=%d", tx); } diff --git a/exercises/14_Power/README.md b/exercises/14_Power/README.md new file mode 100644 index 0000000..3b0d140 --- /dev/null +++ b/exercises/14_Power/README.md @@ -0,0 +1,30 @@ +# Exercise 14: Power (Charging + Visual) + +This exercise is intentionally narrow in scope: +- Detect if a battery is present. +- Detect if USB/VBUS power is present. +- Determine if charging is needed. +- Keep charging enabled through AXP2101 PMU settings. +- Flash the PMU charge LED while charging. +- If fully charged, leave LED off (do nothing). + +OLED behavior: +- For the first 2 minutes after boot, OLED shows: + - `Exercise 14 Power` + - node name (`NODE_LABEL`) + - time (RTC/system time if available, else uptime) + - charging state and battery stats +- After 2 minutes, it switches to a steady `Power Monitor` header while continuing live stats. + +## Meshtastic references used +- `src/Power.cpp` + - charging detection path (`isCharging()`, `isVbusIn()`, battery checks) +- `src/modules/StatusLEDModule.cpp` + - PMU charging LED control via `PMU->setChargingLedMode(...)` + +## Build and upload +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/14_Power +pio run -e ed -t upload +pio device monitor -b 115200 +``` diff --git a/exercises/14_Power/platformio.ini b/exercises/14_Power/platformio.ini new file mode 100644 index 0000000..18ef06f --- /dev/null +++ b/exercises/14_Power/platformio.ini @@ -0,0 +1,65 @@ +; 20260220 Codex +; Exercise 14_Power + +[platformio] +default_envs = ed + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +upload_port = /dev/ttytAMY +monitor_port = /dev/ttytAMY +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +upload_port = /dev/ttytBOB +monitor_port = /dev/ttytBOB +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +upload_port = /dev/ttytCY +monitor_port = /dev/ttytCY +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +upload_port = /dev/ttytDAN +monitor_port = /dev/ttytDAN +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" + +[env:ed] +extends = env +upload_port = /dev/ttytED +monitor_port = /dev/ttytED +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"ED\" diff --git a/exercises/14_Power/src/main.cpp b/exercises/14_Power/src/main.cpp new file mode 100644 index 0000000..ef300be --- /dev/null +++ b/exercises/14_Power/src/main.cpp @@ -0,0 +1,192 @@ +// 20260220 Codex +// Exercise 14: Power / Charging Visual Indicator + +#include +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "POWER" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static XPowersLibInterface *g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); + +static const uint32_t kBlinkIntervalMs = 500; +static const uint32_t kStatusIntervalMs = 1000; +static const uint32_t kSerialIntervalMs = 2000; +static const uint32_t kStartupDisplayMs = 120000; + +static bool g_ledOn = false; +static uint32_t g_lastBlinkMs = 0; +static uint32_t g_lastStatusMs = 0; +static uint32_t g_lastSerialMs = 0; +static uint32_t g_bootMs = 0; + +static void oledShow(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static void setChargeLed(bool on) +{ + if (!g_pmu) return; + g_pmu->setChargingLedMode(on ? XPOWERS_CHG_LED_ON : XPOWERS_CHG_LED_OFF); +} + +static void setupChargingDefaults() +{ + if (!g_pmu) return; + g_pmu->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + g_pmu->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); +} + +static const char *powerState(bool batteryPresent, bool usbPresent, bool fullyCharged, bool chargingNow) +{ + if (!batteryPresent) return "NO BATTERY"; + if (!usbPresent) return "USB NOT PRESENT"; + if (fullyCharged) return "FULL"; + if (chargingNow) return "CHARGING"; + return "IDLE"; +} + +static void formatDisplayTime(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "Time: %02d:%02d:%02d", tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec); + return; + } + + uint32_t sec = millis() / 1000; + uint32_t hh = sec / 3600; + uint32_t mm = (sec % 3600) / 60; + uint32_t ss = sec % 60; + snprintf(out, outSize, "Uptime: %02lu:%02lu:%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss); +} + +void setup() +{ + g_bootMs = millis(); + + Serial.begin(115200); + delay(1200); + Serial.println("Exercise 14_Power boot"); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "Booting..."); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("ERROR: PMU init failed"); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "PMU init FAILED"); + return; + } + + setupChargingDefaults(); + setChargeLed(false); + + Serial.println("PMU init OK"); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "PMU ready"); +} + +void loop() +{ + if (!g_pmu) { + delay(1000); + return; + } + + const bool batteryPresent = g_pmu->isBatteryConnect(); + const bool usbPresent = g_pmu->isVbusIn(); + const bool chargingNow = g_pmu->isCharging(); + const int battPercent = g_pmu->getBatteryPercent(); + const float battV = g_pmu->getBattVoltage() / 1000.0f; + + const bool fullyCharged = batteryPresent && battPercent >= 100; + const bool shouldCharge = batteryPresent && usbPresent && !fullyCharged; + + if (shouldCharge) { + if (millis() - g_lastBlinkMs >= kBlinkIntervalMs) { + g_lastBlinkMs = millis(); + g_ledOn = !g_ledOn; + setChargeLed(g_ledOn); + } + } else { + g_ledOn = false; + setChargeLed(false); + } + + if (millis() - g_lastStatusMs >= kStatusIntervalMs) { + g_lastStatusMs = millis(); + + char l1[32]; + char l2[32]; + char l3[32]; + char l4[32]; + char l5[32]; + + const char *state = powerState(batteryPresent, usbPresent, fullyCharged, chargingNow); + const bool startupWindow = (millis() - g_bootMs) < kStartupDisplayMs; + + if (startupWindow) { + snprintf(l1, sizeof(l1), "Exercise 14 Power"); + } else { + snprintf(l1, sizeof(l1), "Power Monitor"); + } + + snprintf(l2, sizeof(l2), "Node: %s", NODE_LABEL); + formatDisplayTime(l3, sizeof(l3)); + snprintf(l4, sizeof(l4), "State: %s", state); + if (battPercent >= 0) { + snprintf(l5, sizeof(l5), "VBAT:%.3fV %d%%", battV, battPercent); + } else { + snprintf(l5, sizeof(l5), "VBAT:%.3fV pct:?", battV); + } + + oledShow(l1, l2, l3, l4, l5); + } + + if (millis() - g_lastSerialMs >= kSerialIntervalMs) { + g_lastSerialMs = millis(); + Serial.printf("node=%s usb=%u batt=%u charging=%u full=%u led=%u vbatt=%.3fV pct=%d\r\n", + NODE_LABEL, + usbPresent ? 1 : 0, + batteryPresent ? 1 : 0, + chargingNow ? 1 : 0, + fullyCharged ? 1 : 0, + g_ledOn ? 1 : 0, + battV, + battPercent); + } + + delay(20); +}