diff --git a/.github/workflows/gpad-firmware-version.yml b/.github/workflows/gpad-firmware-version.yml new file mode 100644 index 00000000..9e88bbc1 --- /dev/null +++ b/.github/workflows/gpad-firmware-version.yml @@ -0,0 +1,52 @@ +name: Increment GPAD firmware version + +on: + pull_request_target: + branches: ["main"] + types: [closed] + +permissions: + contents: write + +jobs: + increment-version: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout latest main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure version commit identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Increment patch version and record merged PR + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + for attempt in 1 2 3 4 5; do + git fetch origin main + git checkout -B main origin/main + if git log --fixed-strings --grep="Bump GPAD firmware version for PR #$PR_NUMBER" --format=%H -- Firmware/GPAD_API/FIRMWARE_VERSION | grep -q .; then + echo "Version commit for PR #$PR_NUMBER already exists; nothing to commit." + exit 0 + fi + python3 scripts/bump_gpad_firmware_version.py --pr-number "$PR_NUMBER" + if git diff --quiet -- Firmware/GPAD_API/FIRMWARE_VERSION; then + echo "Version already includes PR #$PR_NUMBER; nothing to commit." + exit 0 + fi + git add Firmware/GPAD_API/FIRMWARE_VERSION + git commit -m "Bump GPAD firmware version for PR #$PR_NUMBER" + if git push origin HEAD:main; then + exit 0 + fi + echo "main advanced while versioning; retrying from latest main ($attempt/5)." + done + echo "Unable to push GPAD firmware version after 5 attempts." >&2 + exit 1 diff --git a/Firmware/GPAD_API/FIRMWARE_VERSION b/Firmware/GPAD_API/FIRMWARE_VERSION new file mode 100644 index 00000000..a60476bf --- /dev/null +++ b/Firmware/GPAD_API/FIRMWARE_VERSION @@ -0,0 +1 @@ +0.58.0 diff --git a/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp b/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp index 5e27f2e1..6262d30d 100644 --- a/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp +++ b/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp @@ -1,6 +1,8 @@ #include "DFPlayer.h" #include "gpad_utility.h" #include "debug_macros.h" +#include "operator_settings.h" +#include "setup_status.h" #include DFRobotDFPlayerMini dfPlayer; @@ -8,9 +10,13 @@ extern HardwareSerial uartSerial2; const int LED_PIN = 13; // Krake const int nDFPlayer_BUSY = 4; // active LOW BUSY pin from DFPlayer +const int MIN_VOLUME_PERCENT = 1; +const int MAX_VOLUME_PERCENT = 100; +const int MIN_DFPLAYER_VOLUME = 1; +const int MAX_DFPLAYER_VOLUME = 30; bool isDFPlayerDetected = false; -int volumeDFPlayer = 20; // Range: 1 to 30 +int volumeDFPlayer = 20; // Range: 1 to 100 (%) int numberFilesDF = 0; // Number of audio files found on SD card extern bool currentlyMuted; char command; @@ -80,17 +86,21 @@ void checkSerial(void) if (command == '+') { - dfPlayer.volumeUp(); + setVolume(volumeDFPlayer + 1); + saveVolumeSetting(volumeDFPlayer); DBG_PRINT(F("Current volume: ")); - DBG_PRINTLN(dfPlayer.readVolume()); + DBG_PRINT(volumeDFPlayer); + DBG_PRINTLN(F("%")); menu_opcoes(); } if (command == '-') { - dfPlayer.volumeDown(); + setVolume(volumeDFPlayer - 1); + saveVolumeSetting(volumeDFPlayer); DBG_PRINT(F("Current volume: ")); - DBG_PRINTLN(dfPlayer.readVolume()); + DBG_PRINT(volumeDFPlayer); + DBG_PRINTLN(F("%")); menu_opcoes(); } @@ -115,7 +125,7 @@ namespace void delayWithYield(const unsigned long durationMs) { const unsigned long startMs = millis(); - while ((millis() - startMs) < durationMs) + while (!millisIntervalElapsed(millis(), startMs, durationMs)) { delay(10); yield(); @@ -138,6 +148,7 @@ void setupDFPlayer() DBG_PRINTLN(F("DFPlayer Mini not detected or not responding.")); DBG_PRINTLN(F("Check wiring, power, SD card, and file names.")); isDFPlayerDetected = false; + setSetupError(SETUP_ERROR_DFPLAYER); return; } @@ -156,7 +167,7 @@ void setupDFPlayer() DBG_PRINTLN(F("Warning: unusual DFPlayer state. Possible clone/module variant, continuing test.")); } - dfPlayer.volume(volumeDFPlayer); + setVolume(volumeDFPlayer); delayWithYield(300); numberFilesDF = dfPlayer.readFileCounts(); @@ -166,25 +177,24 @@ void setupDFPlayer() if (numberFilesDF <= 0) { DBG_PRINTLN(F("Warning: no audio files detected. Use FAT32 SD card and files like 0001.mp3, 0002.mp3.")); + setSetupError(SETUP_ERROR_DFPLAYER_FILES); } - DBG_PRINTLN(F("DFPlayer startup test: playing track 1.")); - dfPlayer.play(1); - delayWithYield(3000); // Give enough time to hear output without starving the scheduler/WDT. - - displayDFPlayerStats(); - menu_opcoes(); + // Do not play a startup track or run slow diagnostic queries during setup. + // The device remains available even if optional audio hardware is missing. + DBG_PRINTLN(F("DFPlayer initialized without blocking startup playback.")); } -void setVolume(int oneToThirty) +void setVolume(int oneToHundred) { - if (oneToThirty < 1) oneToThirty = 1; - if (oneToThirty > 30) oneToThirty = 30; + if (oneToHundred < MIN_VOLUME_PERCENT) oneToHundred = MIN_VOLUME_PERCENT; + if (oneToHundred > MAX_VOLUME_PERCENT) oneToHundred = MAX_VOLUME_PERCENT; - volumeDFPlayer = oneToThirty; + volumeDFPlayer = oneToHundred; + const int dfpVolume = map(volumeDFPlayer, MIN_VOLUME_PERCENT, MAX_VOLUME_PERCENT, MIN_DFPLAYER_VOLUME, MAX_DFPLAYER_VOLUME); if (isDFPlayerDetected) { - dfPlayer.volume(volumeDFPlayer); + dfPlayer.volume(dfpVolume); } } @@ -367,7 +377,7 @@ bool playAlarmLevel(int alarmNumberToPlay) static unsigned long timer = 0; const unsigned long delayPlayLevel = 100; - if (millis() - timer <= delayPlayLevel) + if (!millisIntervalElapsed(millis(), timer, delayPlayLevel + 1)) { return false; } diff --git a/Firmware/GPAD_API/GPAD_API/DFPlayer.h b/Firmware/GPAD_API/GPAD_API/DFPlayer.h index f80f00d2..0c9ae2c8 100644 --- a/Firmware/GPAD_API/GPAD_API/DFPlayer.h +++ b/Firmware/GPAD_API/GPAD_API/DFPlayer.h @@ -21,7 +21,7 @@ void checkSerial(void); void menu_opcoes(); void serialSplashDFP(); -void setVolume(int oneToThirty); +void setVolume(int oneToHundred); extern int volumeDFPlayer; extern int numberFilesDF; diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_API.ino b/Firmware/GPAD_API/GPAD_API/GPAD_API.ino index 0b9d4a84..282c78ed 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_API.ino +++ b/Firmware/GPAD_API/GPAD_API/GPAD_API.ino @@ -53,6 +53,7 @@ #include "alarm_api.h" #include "GPAD_HAL.h" #include "gpad_utility.h" +#include "setup_status.h" #include "gpad_serial.h" #include "Wink.h" #include @@ -65,6 +66,7 @@ #include // WiFi Manager for ESP32 #include +#include #include #include // File System Support #include // req for i2c comm @@ -80,6 +82,7 @@ #include "DFPlayer.h" #include "GPAD_menu.h" #include "mqtt_handler.h" +#include "operator_settings.h" #include "debug_macros.h" AsyncWebServer server(80); @@ -184,41 +187,34 @@ WifiOTA::Manager wifiManager(WiFi, debugSerial); #define DEBUG_SPI 0 -#define DEBUG 0 -// #define DEBUG 4 - #ifndef ENABLE_HEAP_DIAGNOSTICS #define ENABLE_HEAP_DIAGNOSTICS 0 #endif // MQTT Broker -const char *DEFAULT_MQTT_BROKER_NAME = "krakepubinv.cloud.shiftr.io"; -const char *mqtt_user = "krakepubinv"; -const char *mqtt_password = "DlDmkWjp4I4kgDcA"; +const char *const KRAKE_MQTT_BROKER_NAME = "krakepubinv.cloud.shiftr.io"; +const char *const KRAKE_MQTT_USER = "krakepubinv"; +const char *const KRAKE_MQTT_PASSWORD = "DlDmkWjp4I4kgDcA"; +const uint16_t MQTT_BROKER_PORT = 1883; const size_t MQTT_BROKER_MAX_LEN = 64; -const char *MQTT_CONFIG_PATH = "/mqtt.json"; -char mqtt_broker_name[MQTT_BROKER_MAX_LEN] = {0}; -char mqtt_user_storage[32] = {0}; -char mqtt_password_storage[64] = {0}; -struct BrokerOption -{ - uint8_t index; - const char *name; - const char *host; - uint16_t port; - const char *username; - const char *password; - const char *notes; -}; -const BrokerOption brokerOptions[] = { - {2, "Public Shiftr", "public.cloud.shiftr.io", 1883, "public", "public", "Public test broker"}, - {1, "Krake PubInv", "krakepubinv.cloud.shiftr.io", 1883, "krakepubinv", "DlDmkWjp4I4kgDcA", "Project broker"}, +const size_t MQTT_USER_MAX_LEN = 64; +const size_t MQTT_PASSWORD_MAX_LEN = 96; +enum MqttBrokerProfile : uint8_t +{ + MQTT_BROKER_KRAKE_PUBINV = 0, + MQTT_BROKER_CUSTOM = 1, }; -const uint8_t BROKER_OPTION_COUNT = sizeof(brokerOptions) / sizeof(brokerOptions[0]); -const uint8_t DEFAULT_BROKER_INDEX = 1; -const uint8_t MQTT_FAILOVER_THRESHOLD = 5; -uint8_t selectedBrokerIndex = DEFAULT_BROKER_INDEX; -uint8_t activeBrokerIndex = DEFAULT_BROKER_INDEX; +MqttBrokerProfile mqttBrokerProfile = MQTT_BROKER_KRAKE_PUBINV; +char customMqttBrokerName[MQTT_BROKER_MAX_LEN] = {0}; +char customMqttUser[MQTT_USER_MAX_LEN] = {0}; +char customMqttPassword[MQTT_PASSWORD_MAX_LEN] = {0}; +char connectedMqttBrokerName[MQTT_BROKER_MAX_LEN] = {0}; +const char *MQTT_CONFIG_PATH = "/mqtt.json"; +const char *MQTT_PREF_NS = "mqttcfg"; +const char *MQTT_PREF_PROFILE = "profile"; +const char *MQTT_PREF_BROKER = "broker"; +const char *MQTT_PREF_USER = "user"; +const char *MQTT_PREF_PASSWORD = "password"; uint8_t mqttFailCount = 0; const uint8_t MAX_EXTRA_TOPICS = 4; const size_t MAX_TOPIC_LEN = 64; @@ -236,6 +232,8 @@ const uint8_t MAX_WATCH_TOPICS = 12; char watchedTopics[MAX_WATCH_TOPICS][MAX_TOPIC_LEN]; uint8_t watchedTopicCount = 0; unsigned long wifiResetRequestedAtMs = 0; +unsigned long lastWiFiReconnectAttemptMs = 0; +const unsigned long WIFI_RECONNECT_INTERVAL_MS = 5000; const unsigned long MQTT_RECONNECT_INTERVAL_MS = 3000; const uint16_t MQTT_SOCKET_TIMEOUT_SECONDS = 1; unsigned long lastMqttReconnectAttemptMs = 0; @@ -274,9 +272,18 @@ bool isAllowedPublishTopic(const String &topic); bool extractJsonString(const String &json, const char *key, String &value, int startPos = 0, int *valueEndPos = nullptr); bool writeMqttConfig(); bool loadMqttConfig(); -void applyActiveMqttBrokerConfig(); -bool selectMqttBrokerOption(uint8_t index); -int8_t findBrokerOptionByHost(const char *host); +void applyMqttBrokerConfig(); +const char *activeMqttBrokerName(); +const char *activeMqttUser(); +const char *activeMqttPassword(); +const char *activeMqttBrokerLabel(); +const char *connectedMqttBroker(); +bool customMqttBrokerConfigured(); +void requestMqttReconnect(); +bool selectMqttBrokerProfile(uint8_t profile, bool persist = true); +bool configureCustomMqttBroker(const String &broker, const String &user, const String &password); +bool saveMqttBrokerPreferences(); +void loadMqttBrokerPreferences(); bool parseCsvIntoTopics(const String &rawTopics, char dest[][MAX_TOPIC_LEN], uint8_t &count, uint8_t maxTopics); String joinTopicsCsv(const char topics[][MAX_TOPIC_LEN], uint8_t count); @@ -323,12 +330,9 @@ void onWiFiDisconnect(WiFiEvent_t event, WiFiEventInfo_t info) { (void)event; (void)info; #endif - - // OPTION A: Simple Reconnect - //WiFi.begin(); - - // OPTION B: Re-trigger WiFiManager Portal - // wm.startConfigPortal("AutoConnectAP"); + + // Force immediate reconnect attempt in loop (including AP mode). + lastWiFiReconnectAttemptMs = 0; } @@ -351,7 +355,7 @@ void serialSplash() debugSerial.print(F("Alarm Topic: ")); debugSerial.println(subscribe_Alarm_Topic); debugSerial.print(F("Broker: ")); - debugSerial.println(mqtt_broker_name); + debugSerial.println(activeMqttBrokerName()); debugSerial.print(F("Compiled at: ")); debugSerial.println(F(__DATE__ " " __TIME__)); // compile date that is used for a unique identifier debugSerial.println(F(LICENSE)); @@ -368,12 +372,12 @@ void publishOnLineMsg(void) return; } - const unsigned long MESSAGE_PERIOD = 10000; - static unsigned long lastMillis = 0; // Sets timing for periodic MQTT publish message - // publish a message roughly every second. - if ((millis() - lastMillis > MESSAGE_PERIOD) || (millis() < lastMillis)) - { // Check for role over. - lastMillis = lastMillis + MESSAGE_PERIOD; + const uint32_t MESSAGE_PERIOD_MS = 10000; + static uint32_t lastPublishMs = 0; + const uint32_t now = millis(); + if (millisIntervalElapsed(now, lastPublishMs, MESSAGE_PERIOD_MS)) + { + lastPublishMs = now; float rssi = WiFi.RSSI(); char rssiString[8]; @@ -392,70 +396,120 @@ void publishOnLineMsg(void) // debugSerial.print("Device connected at IPaddress: "); //FLE // debugSerial.println(WiFi.localIP()); //FLE -#if defined(HMWK) - digitalWrite(LED_D9, !digitalRead(LED_D9)); // Toggle -#endif } } -void requestMqttReconnect() +const char *activeMqttBrokerName() { - mqttReconnectRequested = true; - lastMqttReconnectAttemptMs = 0; - mqttFailCount = 0; - brokerState = (WiFi.status() == WL_CONNECTED) ? BROKER_RETRYING : BROKER_WAITING_WIFI; - if (client.connected()) - { - client.disconnect(); - } + return mqttBrokerProfile == MQTT_BROKER_CUSTOM ? customMqttBrokerName : KRAKE_MQTT_BROKER_NAME; } -void applyActiveMqttBrokerConfig() +const char *activeMqttUser() { - if (activeBrokerIndex >= BROKER_OPTION_COUNT) - { - activeBrokerIndex = DEFAULT_BROKER_INDEX; - } + return mqttBrokerProfile == MQTT_BROKER_CUSTOM ? customMqttUser : KRAKE_MQTT_USER; +} - const BrokerOption &broker = brokerOptions[activeBrokerIndex]; - strncpy(mqtt_broker_name, broker.host, MQTT_BROKER_MAX_LEN - 1); - mqtt_broker_name[MQTT_BROKER_MAX_LEN - 1] = '\0'; - mqtt_user = broker.username; - mqtt_password = broker.password; - client.setServer(broker.host, broker.port); - client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_SECONDS); +const char *activeMqttPassword() +{ + return mqttBrokerProfile == MQTT_BROKER_CUSTOM ? customMqttPassword : KRAKE_MQTT_PASSWORD; +} + +const char *activeMqttBrokerLabel() +{ + return mqttBrokerProfile == MQTT_BROKER_CUSTOM ? "Custom" : "Krake PubInv"; +} + +const char *connectedMqttBroker() +{ + return (client.connected() && connectedMqttBrokerName[0] != '\0') ? connectedMqttBrokerName : "none"; +} + +bool customMqttBrokerConfigured() +{ + return customMqttBrokerName[0] != '\0'; +} + +bool saveMqttBrokerPreferences() +{ + Preferences prefs; + if (!prefs.begin(MQTT_PREF_NS, false)) { setSetupError(SETUP_ERROR_MQTT_PREFERENCES); return false; } + prefs.putUChar(MQTT_PREF_PROFILE, static_cast(mqttBrokerProfile)); + prefs.putString(MQTT_PREF_BROKER, customMqttBrokerName); + prefs.putString(MQTT_PREF_USER, customMqttUser); + prefs.putString(MQTT_PREF_PASSWORD, customMqttPassword); + prefs.end(); + return true; } -bool selectMqttBrokerOption(uint8_t index) +void loadMqttBrokerPreferences() { - if (index >= BROKER_OPTION_COUNT) + Preferences prefs; + if (!prefs.begin(MQTT_PREF_NS, true)) { setSetupError(SETUP_ERROR_MQTT_PREFERENCES); return; } + String broker = prefs.getString(MQTT_PREF_BROKER, ""); + String user = prefs.getString(MQTT_PREF_USER, ""); + String password = prefs.getString(MQTT_PREF_PASSWORD, ""); + const uint8_t profile = prefs.getUChar(MQTT_PREF_PROFILE, MQTT_BROKER_KRAKE_PUBINV); + prefs.end(); + configureCustomMqttBroker(broker, user, password); + mqttBrokerProfile = (profile == MQTT_BROKER_CUSTOM && customMqttBrokerConfigured()) ? MQTT_BROKER_CUSTOM : MQTT_BROKER_KRAKE_PUBINV; +} + +bool normalizeMqttBrokerName(const String &broker, String &normalized) +{ + normalized = broker; + normalized.trim(); + if (normalized.startsWith("wss://")) normalized.remove(0, 6); + else if (normalized.startsWith("ws://")) normalized.remove(0, 5); + else if (normalized.startsWith("mqtts://")) normalized.remove(0, 8); + else if (normalized.startsWith("mqtt://")) normalized.remove(0, 7); + const int pathPos = normalized.indexOf('/'); + if (pathPos >= 0) normalized.remove(pathPos); + return normalized.length() > 0 && normalized.length() < MQTT_BROKER_MAX_LEN; +} + +bool configureCustomMqttBroker(const String &broker, const String &user, const String &password) +{ + String normalizedBroker; + String normalizedUser = user; + normalizedUser.trim(); + if (!normalizeMqttBrokerName(broker, normalizedBroker) || normalizedUser.length() >= MQTT_USER_MAX_LEN || password.length() >= MQTT_PASSWORD_MAX_LEN) { return false; } - - selectedBrokerIndex = index; - activeBrokerIndex = index; - applyActiveMqttBrokerConfig(); - writeMqttConfig(); - requestMqttReconnect(); + normalizedBroker.toCharArray(customMqttBrokerName, sizeof(customMqttBrokerName)); + normalizedUser.toCharArray(customMqttUser, sizeof(customMqttUser)); + password.toCharArray(customMqttPassword, sizeof(customMqttPassword)); return true; } -int8_t findBrokerOptionByHost(const char *host) +bool selectMqttBrokerProfile(uint8_t profile, bool persist) { - if (host == nullptr || host[0] == '\0') + if (profile > MQTT_BROKER_CUSTOM || (profile == MQTT_BROKER_CUSTOM && !customMqttBrokerConfigured())) { - return -1; + return false; } + mqttBrokerProfile = static_cast(profile); + requestMqttReconnect(); + return !persist || saveMqttBrokerPreferences(); +} - for (uint8_t i = 0; i < BROKER_OPTION_COUNT; i++) +void requestMqttReconnect() +{ + mqttReconnectRequested = true; + connectedMqttBrokerName[0] = '\0'; + lastMqttReconnectAttemptMs = 0; + mqttFailCount = 0; + brokerState = (WiFi.status() == WL_CONNECTED) ? BROKER_RETRYING : BROKER_WAITING_WIFI; + if (client.connected()) { - if (strcmp(host, brokerOptions[i].host) == 0) - { - return static_cast(i); - } + client.disconnect(); } - return -1; +} + +void applyMqttBrokerConfig() +{ + client.setServer(activeMqttBrokerName(), MQTT_BROKER_PORT); + client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_SECONDS); } bool reconnect(bool force = false) @@ -474,13 +528,13 @@ bool reconnect(bool force = false) } const unsigned long now = millis(); - if (!force && lastMqttReconnectAttemptMs != 0 && (now - lastMqttReconnectAttemptMs) < MQTT_RECONNECT_INTERVAL_MS) + if (!force && lastMqttReconnectAttemptMs != 0 && !millisIntervalElapsed(now, lastMqttReconnectAttemptMs, MQTT_RECONNECT_INTERVAL_MS)) { return false; } lastMqttReconnectAttemptMs = now; mqttReconnectRequested = false; - applyActiveMqttBrokerConfig(); + applyMqttBrokerConfig(); brokerState = BROKER_CONNECTING; char clientId[sizeof(COMPANY_NAME) + MAC_ADDRESS_STRING_LENGTH + 1]; @@ -490,9 +544,9 @@ bool reconnect(bool force = false) debugSerial.print("Attempting MQTT connection at: "); debugSerial.print(millis()); debugSerial.print(" broker="); - debugSerial.print(mqtt_broker_name); + debugSerial.print(activeMqttBrokerName()); debugSerial.print(" user="); - debugSerial.print((mqtt_user != nullptr && mqtt_user[0] != '\0') ? mqtt_user : ""); + debugSerial.print(activeMqttUser()); debugSerial.print(" ip="); debugSerial.print(WiFi.localIP()); debugSerial.print(" rssi="); @@ -504,17 +558,14 @@ bool reconnect(bool force = false) debugSerial.print(" pub="); debugSerial.print(publish_Ack_Topic); debugSerial.print(" ... "); - if (client.connect(clientId, mqtt_user, mqtt_password, publish_Ack_Topic, 1, true, willPayload)) + if (client.connect(clientId, activeMqttUser(), activeMqttPassword(), publish_Ack_Topic, 1, true, willPayload)) { debugSerial.print("success at: "); debugSerial.println(millis()); mqttFailCount = 0; brokerState = BROKER_CONNECTED; - if (selectedBrokerIndex != activeBrokerIndex) - { - selectedBrokerIndex = activeBrokerIndex; - writeMqttConfig(); - } + strncpy(connectedMqttBrokerName, activeMqttBrokerName(), sizeof(connectedMqttBrokerName) - 1); + connectedMqttBrokerName[sizeof(connectedMqttBrokerName) - 1] = '\0'; char onlinePayload[DEVICE_ROLE_MAX_LEN + 8]; snprintf(onlinePayload, sizeof(onlinePayload), "%s online", device_role); queueMqtt(publish_Ack_Topic, onlinePayload, true); @@ -540,17 +591,6 @@ bool reconnect(bool force = false) debugSerial.println(mqttStateDescription(client.state())); mqttFailCount++; brokerState = BROKER_FAILED; - if (mqttFailCount >= MQTT_FAILOVER_THRESHOLD) - { - mqttFailCount = 0; - brokerState = BROKER_RETRYING; - activeBrokerIndex = (activeBrokerIndex + 1) % BROKER_OPTION_COUNT; - const BrokerOption &nextBroker = brokerOptions[activeBrokerIndex]; - debugSerial.print("MQTT failover to "); - debugSerial.print(nextBroker.name); - debugSerial.print(" host="); - debugSerial.println(nextBroker.host); - } yield(); return false; } @@ -896,9 +936,7 @@ String trackedKrakesJson() payload += "\",\"mqttConnected\":" + String(client.connected() ? "true" : "false") + ","; payload += "\"mqttState\":" + String(client.state()) + ","; payload += "\"mqttStateText\":\"" + jsonEscape(String(mqttStateDescription(client.state()))) + "\","; - payload += "\"selectedBrokerIndex\":" + String(selectedBrokerIndex) + ","; - payload += "\"activeBrokerIndex\":" + String(activeBrokerIndex) + ","; - payload += "\"broker\":\"" + jsonEscape(String(mqtt_broker_name)) + "\","; + payload += "\"broker\":\"" + jsonEscape(String(activeMqttBrokerName())) + "\","; payload += "\"subscribeAlarmTopic\":\"" + jsonEscape(String(subscribe_Alarm_Topic)) + "\","; payload += "\"publishAckTopic\":\"" + jsonEscape(String(publish_Ack_Topic)) + "\","; payload += "\"publishDefaultTopic\":\"" + jsonEscape(String(publish_Default_Topic)) + "\","; @@ -914,9 +952,9 @@ String trackedKrakesJson() { continue; } - const bool online = (now - trackedKrakes[i].lastStatusMs) <= KRAKE_ONLINE_TIMEOUT_MS; + const bool online = elapsedMillis(now, trackedKrakes[i].lastStatusMs) <= KRAKE_ONLINE_TIMEOUT_MS; const bool topicParticipant = trackedKrakes[i].watchedTopicSeenMs > 0 && - (now - trackedKrakes[i].watchedTopicSeenMs) <= TOPIC_PARTICIPATION_TIMEOUT_MS; + elapsedMillis(now, trackedKrakes[i].watchedTopicSeenMs) <= TOPIC_PARTICIPATION_TIMEOUT_MS; if (!first) { @@ -930,8 +968,8 @@ String trackedKrakesJson() payload += "\"rssi\":" + String(trackedKrakes[i].rssi) + ","; payload += "\"status\":\"" + jsonEscape(String(trackedKrakes[i].status)) + "\","; payload += "\"lastTopic\":\"" + jsonEscape(String(trackedKrakes[i].lastTopic)) + "\","; - payload += "\"secondsSinceStatus\":" + String((now - trackedKrakes[i].lastStatusMs) / 1000) + ","; - payload += "\"secondsSinceTopic\":" + String((trackedKrakes[i].watchedTopicSeenMs == 0) ? -1 : ((now - trackedKrakes[i].watchedTopicSeenMs) / 1000)); + payload += "\"secondsSinceStatus\":" + String(elapsedMillis(now, trackedKrakes[i].lastStatusMs) / 1000) + ","; + payload += "\"secondsSinceTopic\":" + String((trackedKrakes[i].watchedTopicSeenMs == 0) ? -1 : (elapsedMillis(now, trackedKrakes[i].watchedTopicSeenMs) / 1000)); payload += "}"; } @@ -1136,6 +1174,13 @@ bool parseCsvIntoTopics(const String &rawTopics, char dest[][MAX_TOPIC_LEN], uin bool writeMqttConfig() { + const bool brokerPreferencesSaved = saveMqttBrokerPreferences(); + if (!WifiOTA::isLittleFSMounted()) + { + debugSerial.println(F("Cannot save /mqtt.json: LittleFS is unavailable.")); + return false; + } + File file = LittleFS.open(MQTT_CONFIG_PATH, "w"); if (!file) { @@ -1144,10 +1189,6 @@ bool writeMqttConfig() } String payload = "{"; - payload += "\"broker\":\"" + jsonEscape(String(mqtt_broker_name)) + "\","; - payload += "\"selectedBrokerIndex\":\"" + String(selectedBrokerIndex) + "\","; - payload += "\"mqttUser\":\"" + jsonEscape(String(mqtt_user != nullptr ? mqtt_user : "")) + "\","; - payload += "\"mqttPassword\":\"" + jsonEscape(String(mqtt_password != nullptr ? mqtt_password : "")) + "\","; payload += "\"subscribeTopic\":\"" + jsonEscape(String(subscribe_Alarm_Topic)) + "\","; payload += "\"publishTopic\":\"" + jsonEscape(String(publish_Ack_Topic)) + "\","; payload += "\"subscribeTopics\":\"" + jsonEscape(joinedExtraTopics()) + "\","; @@ -1159,11 +1200,18 @@ bool writeMqttConfig() const size_t written = file.print(payload); file.close(); - return written == payload.length(); + return brokerPreferencesSaved && written == payload.length(); } bool loadMqttConfig() { + loadMqttBrokerPreferences(); + if (!WifiOTA::isLittleFSMounted()) + { + debugSerial.println(F("Cannot load /mqtt.json: LittleFS is unavailable.")); + return false; + } + if (!LittleFS.exists(MQTT_CONFIG_PATH)) { return false; @@ -1180,47 +1228,6 @@ bool loadMqttConfig() file.close(); String value; - bool loadedBrokerIndex = false; - if (extractJsonString(content, "broker", value)) - { - value.trim(); - if (value.length() > 0 && value.length() < MQTT_BROKER_MAX_LEN) - { - value.toCharArray(mqtt_broker_name, MQTT_BROKER_MAX_LEN); - } - } - - if (extractJsonString(content, "selectedBrokerIndex", value)) - { - value.trim(); - const int parsedIndex = value.toInt(); - if (value.length() > 0 && parsedIndex >= 0 && parsedIndex < BROKER_OPTION_COUNT) - { - selectedBrokerIndex = static_cast(parsedIndex); - activeBrokerIndex = selectedBrokerIndex; - loadedBrokerIndex = true; - } - } - - if (extractJsonString(content, "mqttUser", value)) - { - value.trim(); - if (value.length() < sizeof(mqtt_user_storage)) - { - value.toCharArray(mqtt_user_storage, sizeof(mqtt_user_storage)); - mqtt_user = mqtt_user_storage; - } - } - - if (extractJsonString(content, "mqttPassword", value)) - { - if (value.length() < sizeof(mqtt_password_storage)) - { - value.toCharArray(mqtt_password_storage, sizeof(mqtt_password_storage)); - mqtt_password = mqtt_password_storage; - } - } - if (extractJsonString(content, "subscribeTopic", value)) { value.trim(); @@ -1267,16 +1274,6 @@ bool loadMqttConfig() value.toCharArray(device_role, DEVICE_ROLE_MAX_LEN); } } - if (!loadedBrokerIndex) - { - const int8_t matchedIndex = findBrokerOptionByHost(mqtt_broker_name); - if (matchedIndex >= 0) - { - selectedBrokerIndex = static_cast(matchedIndex); - activeBrokerIndex = selectedBrokerIndex; - } - } - applyActiveMqttBrokerConfig(); return true; } @@ -1308,37 +1305,35 @@ bool applyMuteSetting(const String &rawValue) { return false; } - setMuted(requestedMutedState); + if (requestedMutedState) + { + setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); + } + else + { + clearMuteTimeout(); + setMuted(false); + } return true; } bool applyBrokerSetting(const String &broker) { - String normalized = broker; - normalized.trim(); - if (normalized.startsWith("wss://")) - { - normalized.remove(0, 6); - } - else if (normalized.startsWith("ws://")) + String normalized; + if (!normalizeMqttBrokerName(broker, normalized)) { - normalized.remove(0, 5); + return false; } - - if (normalized.length() == 0 || normalized.length() >= MQTT_BROKER_MAX_LEN) + if (normalized.equalsIgnoreCase(KRAKE_MQTT_BROKER_NAME) || normalized.equalsIgnoreCase("Krake PubInv")) { - return false; + mqttBrokerProfile = MQTT_BROKER_KRAKE_PUBINV; + return true; } - - for (uint8_t i = 0; i < BROKER_OPTION_COUNT; i++) + if (customMqttBrokerConfigured() && normalized.equalsIgnoreCase(customMqttBrokerName)) { - if (normalized.equalsIgnoreCase(brokerOptions[i].host) || - normalized.equalsIgnoreCase(brokerOptions[i].name)) - { - return selectMqttBrokerOption(i); - } + mqttBrokerProfile = MQTT_BROKER_CUSTOM; + return true; } - return false; } @@ -1378,9 +1373,6 @@ void applyExtraTopicsSetting(const String &topics) // Function to turn on all lamps void turnOnAllLamps() { -#if defined(HMWK) - digitalWrite(LED_D9, HIGH); -#endif digitalWrite(LIGHT0, HIGH); digitalWrite(LIGHT1, HIGH); digitalWrite(LIGHT2, HIGH); @@ -1389,9 +1381,6 @@ void turnOnAllLamps() } void turnOffAllLamps() { -#if defined(HMWK) - digitalWrite(LED_D9, LOW); -#endif digitalWrite(LIGHT0, LOW); digitalWrite(LIGHT1, LOW); digitalWrite(LIGHT2, LOW); @@ -1399,7 +1388,43 @@ void turnOffAllLamps() digitalWrite(LIGHT4, LOW); } -// Handeler for MQTT subscribed messages +// Application-layer actions for parsed commands and menu responses +bool publishAlarmAction(const char *responseType, const char *alarmId) +{ + return publishGPAPResponse(&client, responseType, alarmId); +} + +bool mqttClientConnected() +{ + return client.connected(); +} + +void publishSystemInfoLine(const char *line) +{ + if (client.connected()) + { + publishAck(&client, line); + } +} + +void applyInterpretedCommand(const InterpretedCommand &result, Stream *serialport) +{ + requestAlarmRefresh(serialport, result.includeAudioRefresh); + if (result.publishSystemInfo && client.connected()) + { + printSystemInfo(nullptr, publishSystemInfoLine); + } + if (result.publishResetAck && client.connected()) + { + publishAck(&client, "Software reset requested."); + } + if (result.restartRequested) + { + delay(100); + ESP.restart(); + } +} + void callback(char *topic, byte *payload, unsigned int length) { // Note: We will check for topic or topics in the future... @@ -1436,8 +1461,8 @@ void callback(char *topic, byte *payload, unsigned int length) } noteLcdQueueMessageReceived(); markWatchedTopicParticipant(topic); - interpretBuffer(mbuff, m, &debugSerial, &client); // Process the MQTT message - requestAlarmRefresh(&debugSerial); + const InterpretedCommand result = interpretBuffer(mbuff, m, &debugSerial); // Process the MQTT message + applyInterpretedCommand(result, &debugSerial); } } // end call back @@ -1559,6 +1584,15 @@ void addWebUiHeaders(AsyncWebServerResponse *response, bool noStore = true) response->addHeader("X-Content-Type-Options", "nosniff"); } +bool requestAcceptsGzip(AsyncWebServerRequest *request) +{ + if (request == nullptr || !request->hasHeader("Accept-Encoding")) + { + return false; + } + return request->getHeader("Accept-Encoding")->value().indexOf("gzip") >= 0; +} + void sendTextResponse(AsyncWebServerRequest *request, int code, const char *contentType, const String &body) { AsyncWebServerResponse *response = request->beginResponse(code, contentType, body); @@ -1615,14 +1649,22 @@ bool isNoStorePath(const char *path) strcmp(dot, ".css") == 0 || strcmp(dot, ".json") == 0; } -void sendStaticFile(AsyncWebServerRequest *request, const char *path, const char *contentType) +void sendStaticFile(AsyncWebServerRequest *request, const char *path, const char *contentType, int statusCode = 200) { + if (!WifiOTA::isLittleFSMounted()) + { + sendTextResponse(request, 503, "text/plain", "LittleFS unavailable"); + return; + } + char gzipPath[64]; snprintf(gzipPath, sizeof(gzipPath), "%s.gz", path); - if (LittleFS.exists(gzipPath)) + if (LittleFS.exists(gzipPath) && requestAcceptsGzip(request)) { AsyncWebServerResponse *response = request->beginResponse(LittleFS, gzipPath, contentType); + response->setCode(statusCode); response->addHeader("Content-Encoding", "gzip"); + response->addHeader("Vary", "Accept-Encoding"); addWebUiHeaders(response, isNoStorePath(path)); request->send(response); return; @@ -1630,6 +1672,8 @@ void sendStaticFile(AsyncWebServerRequest *request, const char *path, const char if (LittleFS.exists(path)) { AsyncWebServerResponse *response = request->beginResponse(LittleFS, path, contentType); + response->setCode(statusCode); + response->addHeader("Vary", "Accept-Encoding"); addWebUiHeaders(response, isNoStorePath(path)); request->send(response); return; @@ -1642,8 +1686,37 @@ void sendStaticFile(AsyncWebServerRequest *request, const char *path) sendStaticFile(request, path, contentTypeForPath(path)); } +void sendSourceFile(AsyncWebServerRequest *request, const char *path, const char *contentType) +{ + if (!WifiOTA::isLittleFSMounted()) + { + sendTextResponse(request, 503, "text/plain", "LittleFS unavailable"); + return; + } + if (!LittleFS.exists(path)) + { + sendTextResponse(request, 404, "text/plain", "not found"); + return; + } + + AsyncWebServerResponse *response = request->beginResponse(LittleFS, path, contentType); + addWebUiHeaders(response, isNoStorePath(path)); + request->send(response); +} + void sendTemplateFile(AsyncWebServerRequest *request, const char *path) { + if (!WifiOTA::isLittleFSMounted()) + { + sendTextResponse(request, 503, "text/plain", "LittleFS unavailable"); + return; + } + if (!LittleFS.exists(path)) + { + sendTextResponse(request, 404, "text/plain", "not found"); + return; + } + AsyncWebServerResponse *response = request->beginResponse(LittleFS, path, contentTypeForPath(path), false, templateProcessor); addWebUiHeaders(response); request->send(response); @@ -1653,11 +1726,46 @@ void sendTemplateFile(AsyncWebServerRequest *request, const char *path) void setupOTA() { + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Content-Type"); + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Private-Network", "true"); // Route for root / web page + server.on("/littlefs-status", HTTP_GET, [](AsyncWebServerRequest *request) + { + String payload = "{\"mounted\":"; + payload += WifiOTA::isLittleFSMounted() ? "true" : "false"; + if (WifiOTA::isLittleFSMounted()) + { + payload += ",\"totalBytes\":" + String(LittleFS.totalBytes()); + payload += ",\"usedBytes\":" + String(LittleFS.usedBytes()); + payload += ",\"indexPresent\":" + String(LittleFS.exists("/index.html") ? "true" : "false"); + payload += ",\"wifiConfigMirrorPresent\":" + String(LittleFS.exists("/wifi.json") ? "true" : "false"); + payload += ",\"mqttConfigPresent\":" + String(LittleFS.exists(MQTT_CONFIG_PATH) ? "true" : "false"); + } + payload += ",\"wifiCredentialsStored\":" + String(wifiManager.hasSavedCredentials() ? "true" : "false"); + payload += "}"; + sendTextResponse(request, 200, "application/json", payload); }); + server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { sendTemplateFile(request, "/index.html"); }); + server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request) + { sendSourceFile(request, "/style.css", "text/css"); }); + + server.on("/favicon.png", HTTP_GET, [](AsyncWebServerRequest *request) + { sendSourceFile(request, "/favicon.png", "image/png"); }); + + server.on("/index.html", HTTP_GET, [](AsyncWebServerRequest *request) + { sendTemplateFile(request, "/index.html"); }); + + server.on("/manual", HTTP_GET, [](AsyncWebServerRequest *request) + { sendStaticFile(request, "/manual.html", "text/html"); }); + + server.on("/PMD_GPAD_API", HTTP_GET, [](AsyncWebServerRequest *request) + { sendStaticFile(request, "/PMD_GPAD_API.html", "text/html"); }); + server.on("/monitor", HTTP_GET, [](AsyncWebServerRequest *request) { sendTemplateFile(request, "/monitor.html"); }); @@ -1707,9 +1815,6 @@ void setupOTA() server.on("/settings", HTTP_GET, [](AsyncWebServerRequest *request) { sendStaticFile(request, "/settings.html", "text/html"); }); - server.on("/setup", HTTP_GET, [](AsyncWebServerRequest *request) - { sendStaticFile(request, "/setup.html", "text/html"); }); - server.on("/wifi", HTTP_GET, [](AsyncWebServerRequest *request) { String ssid; @@ -1754,10 +1859,10 @@ void setupOTA() } if (!wifiManager.saveCredentials(trimmedSsid, trimmedPassword)) { - request->send(500, "text/plain", "failed to save wifi.json"); + request->send(500, "text/plain", "failed to save WiFi credentials"); return; } - request->send(200, "text/plain", "wifi.json saved; restart or reconnect the device to apply"); }); + request->send(200, "text/plain", "WiFi credentials saved; restart or reconnect the device to apply"); }); server.on("/manual", HTTP_GET, [](AsyncWebServerRequest *request) { sendStaticFile(request, "/manual.html", "text/html"); }); @@ -1771,33 +1876,16 @@ void setupOTA() server.on("/settings-data", HTTP_GET, [](AsyncWebServerRequest *request) { String payload = "{"; - payload += "\"broker\":\"" + jsonEscape(String(mqtt_broker_name)) + "\","; - payload += "\"mqttUser\":\"" + jsonEscape(String(mqtt_user != nullptr ? mqtt_user : "")) + "\","; + payload += "\"broker\":\"" + jsonEscape(String(activeMqttBrokerName())) + "\","; + payload += "\"brokerProfile\":\"" + String(mqttBrokerProfile == MQTT_BROKER_CUSTOM ? "custom" : "krake") + "\","; + payload += "\"customBroker\":\"" + jsonEscape(String(customMqttBrokerName)) + "\","; + payload += "\"customUser\":\"" + jsonEscape(String(customMqttUser)) + "\","; + payload += "\"customPasswordStored\":" + String(customMqttPassword[0] != '\0' ? "true" : "false") + ","; + payload += "\"connectedBroker\":\"" + jsonEscape(String(connectedMqttBroker())) + "\","; payload += "\"mqttConnected\":" + String(client.connected() ? "true" : "false") + ","; payload += "\"mqttState\":" + String(client.state()) + ","; payload += "\"mqttStateText\":\"" + jsonEscape(String(mqttStateDescription(client.state()))) + "\","; payload += "\"brokerState\":\"" + jsonEscape(String(brokerConnectionStateText())) + "\","; - payload += "\"selectedBrokerIndex\":" + String(selectedBrokerIndex) + ","; - payload += "\"activeBrokerIndex\":" + String(activeBrokerIndex) + ","; - payload += "\"brokerOptions\":["; - for (uint8_t i = 0; i < BROKER_OPTION_COUNT; i++) - { - if (i > 0) - { - payload += ","; - } - payload += "{"; - payload += "\"index\":" + String(brokerOptions[i].index) + ","; - payload += "\"name\":\"" + jsonEscape(String(brokerOptions[i].name)) + "\","; - payload += "\"host\":\"" + jsonEscape(String(brokerOptions[i].host)) + "\","; - payload += "\"port\":" + String(brokerOptions[i].port) + ","; - payload += "\"username\":\"" + jsonEscape(String(brokerOptions[i].username)) + "\","; - payload += "\"password\":\"" + jsonEscape(String(brokerOptions[i].password)) + "\","; - payload += "\"notes\":\"" + jsonEscape(String(brokerOptions[i].notes)) + "\","; - payload += "\"active\":" + String(i == selectedBrokerIndex ? "true" : "false"); - payload += "}"; - } - payload += "],"; payload += "\"subscribeTopic\":\"" + jsonEscape(String(subscribe_Alarm_Topic)) + "\","; payload += "\"publishTopic\":\"" + jsonEscape(String(publish_Ack_Topic)) + "\","; payload += "\"extraTopics\":\"" + jsonEscape(joinedExtraTopics()) + "\","; @@ -1805,6 +1893,8 @@ void setupOTA() payload += "\"publishTopics\":\"" + jsonEscape(joinedPublishTopics()) + "\","; payload += "\"publishDefaultTopic\":\"" + jsonEscape(String(publish_Default_Topic)) + "\","; payload += "\"role\":\"" + jsonEscape(String(device_role)) + "\","; + payload += "\"volume\":" + String(volumeDFPlayer) + ","; + payload += "\"muteTimeoutMinutes\":" + String(muteTimeoutMinutes) + ","; payload += "\"muted\":" + String(isMuted() ? "true" : "false"); payload += "}"; sendTextResponse(request, 200, "application/json", payload); }); @@ -1812,6 +1902,21 @@ void setupOTA() server.on("/config", HTTP_POST, [](AsyncWebServerRequest *request) { String errorMessage; + if (request->hasParam("customBroker", true) || request->hasParam("customUser", true) || request->hasParam("customPassword", true)) + { + const String customBroker = request->hasParam("customBroker", true) ? request->getParam("customBroker", true)->value() : String(customMqttBrokerName); + const String customUser = request->hasParam("customUser", true) ? request->getParam("customUser", true)->value() : String(customMqttUser); + const String customPassword = request->hasParam("customPassword", true) ? request->getParam("customPassword", true)->value() : String(customMqttPassword); + if (!configureCustomMqttBroker(customBroker, customUser, customPassword)) errorMessage += "invalid custom broker config;"; + } + if (request->hasParam("brokerProfile", true)) + { + String profile = request->getParam("brokerProfile", true)->value(); + profile.trim(); profile.toLowerCase(); + if (profile == "krake") mqttBrokerProfile = MQTT_BROKER_KRAKE_PUBINV; + else if (profile == "custom" && customMqttBrokerConfigured()) mqttBrokerProfile = MQTT_BROKER_CUSTOM; + else errorMessage += "invalid brokerProfile;"; + } if (request->hasParam("broker", true)) { String broker = request->getParam("broker", true)->value(); @@ -1829,35 +1934,6 @@ void setupOTA() } } - if (request->hasParam("mqttUser", true)) - { - String user = request->getParam("mqttUser", true)->value(); - user.trim(); - if (user.length() >= sizeof(mqtt_user_storage)) - { - errorMessage += "invalid mqttUser;"; - } - else - { - user.toCharArray(mqtt_user_storage, sizeof(mqtt_user_storage)); - mqtt_user = mqtt_user_storage; - } - } - - if (request->hasParam("mqttPassword", true)) - { - String password = request->getParam("mqttPassword", true)->value(); - if (password.length() >= sizeof(mqtt_password_storage)) - { - errorMessage += "invalid mqttPassword;"; - } - else - { - password.toCharArray(mqtt_password_storage, sizeof(mqtt_password_storage)); - mqtt_password = mqtt_password_storage; - } - } - if (request->hasParam("role", true)) { if (!applyRoleSetting(request->getParam("role", true)->value())) @@ -1930,6 +2006,51 @@ void setupOTA() requestMqttReconnect(); request->send(200, "text/plain", "config updated"); }); + server.on("/settings/sound", HTTP_POST, [](AsyncWebServerRequest *request) + { + if (!request->hasParam("volume", true) || !request->hasParam("muteTimeoutMinutes", true)) + { + request->send(400, "text/plain", "missing sound setting"); + return; + } + String volumeText = request->getParam("volume", true)->value(); + String muteMinutesText = request->getParam("muteTimeoutMinutes", true)->value(); + volumeText.trim(); + muteMinutesText.trim(); + for (size_t i = 0; i < volumeText.length(); i++) + { + if (!isDigit(volumeText[i])) + { + request->send(400, "text/plain", "invalid sound setting"); + return; + } + } + for (size_t i = 0; i < muteMinutesText.length(); i++) + { + if (!isDigit(muteMinutesText[i])) + { + request->send(400, "text/plain", "invalid sound setting"); + return; + } + } + const int requestedVolume = volumeText.toInt(); + const int requestedMuteMinutes = muteMinutesText.toInt(); + if (volumeText.length() == 0 || muteMinutesText.length() == 0 || + requestedVolume < OPERATOR_VOLUME_MIN_PERCENT || requestedVolume > OPERATOR_VOLUME_MAX_PERCENT || + requestedMuteMinutes < OPERATOR_MUTE_TIMEOUT_MIN_MINUTES || requestedMuteMinutes > OPERATOR_MUTE_TIMEOUT_MAX_MINUTES) + { + request->send(400, "text/plain", "invalid sound setting"); + return; + } + setVolume(requestedVolume); + muteTimeoutMinutes = requestedMuteMinutes; + if (!saveVolumeSetting(volumeDFPlayer) || !saveMuteTimeoutMinutesSetting(muteTimeoutMinutes)) + { + request->send(500, "text/plain", "failed to save sound setting"); + return; + } + request->send(200, "text/plain", "sound settings saved"); }); + server.on("/settings/mute", HTTP_POST, [](AsyncWebServerRequest *request) { if (!request->hasParam("muted", true)) @@ -1958,7 +2079,9 @@ void setupOTA() request->send(400, "text/plain", "invalid broker"); return; } - request->send(200, "text/plain", "broker updated"); }); + writeMqttConfig(); + requestMqttReconnect(); + request->send(200, "text/plain", "broker reconnect requested"); }); server.on("/settings/topics", HTTP_POST, [](AsyncWebServerRequest *request) { @@ -2045,6 +2168,9 @@ void setupOTA() } String topic = request->getParam("topic", true)->value(); String payload = request->hasParam("payload", true) ? request->getParam("payload", true)->value() : ""; + const bool clearRetained = request->hasParam("clearRetained", true) && request->getParam("clearRetained", true)->value() == "1"; + const bool retain = clearRetained || (request->hasParam("retain", true) && request->getParam("retain", true)->value() == "1"); + if (clearRetained) payload = ""; topic.trim(); if (topic.length() == 0) { @@ -2056,7 +2182,7 @@ void setupOTA() request->send(403, "text/plain", "topic not allowed; use saved broker-console publish topics"); return; } - bool ok = queueMqtt(topic.c_str(), payload.c_str()); + bool ok = queueMqtt(topic.c_str(), payload.c_str(), retain); if (ok && topic.length() < MAX_TOPIC_LEN) { topic.toCharArray(publish_Default_Topic, MAX_TOPIC_LEN); @@ -2077,7 +2203,7 @@ void setupOTA() } writeMqttConfig(); } - request->send(ok ? 200 : 500, "text/plain", ok ? "published" : "publish failed"); }); + request->send(ok ? 200 : 500, "text/plain", ok ? (clearRetained ? "retained message clear queued" : "published") : "publish failed"); }); AsyncStaticWebHandler *staticHandler = &server.serveStatic("/", LittleFS, "/"); staticHandler->setDefaultFile("index.html"); @@ -2085,9 +2211,14 @@ void setupOTA() server.onNotFound([](AsyncWebServerRequest *request) { + if (request->method() == AsyncWebRequestMethod::HTTP_OPTIONS) + { + sendTextResponse(request, 204, "text/plain", ""); + return; + } if (request->method() == AsyncWebRequestMethod::HTTP_GET) { - sendStaticFile(request, "/index.html", "text/html"); + sendStaticFile(request, "/404.html", "text/html", 404); return; } sendTextResponse(request, 404, "text/plain", "not found"); @@ -2098,7 +2229,7 @@ void setupOTA() void handleWifiConnected() { -#if defined HMWK || defined KRAKE +#if defined(KRAKE) if (!client.connected()) { reconnect(true); @@ -2126,11 +2257,6 @@ void setup() debugSerial.begin(BAUDRATE); serialLogBuffer[0] = '\0'; serialLogLength = 0; - const unsigned long serialStartMs = millis(); - while (!debugSerial && (millis() - serialStartMs) < 2000) - { - delay(10); // wait briefly for native USB without starving the scheduler - } serialSplash(); // We call this a second time to get the MAC on the screen // clearLCD(); @@ -2160,7 +2286,16 @@ void setup() // Setup the SWITCH_MUTE // Setup the SWITCH_ENCODER - WifiOTA::initLittleFS(); + if (!WifiOTA::initLittleFS()) + { + setSetupError(SETUP_ERROR_LITTLEFS); + } + + volumeDFPlayer = loadVolumeSetting(volumeDFPlayer); + muteTimeoutMinutes = loadMuteTimeoutMinutesSetting(muteTimeoutMinutes); + + volumeDFPlayer = loadVolumeSetting(volumeDFPlayer); + muteTimeoutMinutes = loadMuteTimeoutMinutesSetting(muteTimeoutMinutes); WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); wifiManager.initialize(); @@ -2174,34 +2309,16 @@ void setup() #endif debugSerial.setTimeout(SERIAL_TIMEOUT_MS); - strncpy(mqtt_broker_name, DEFAULT_MQTT_BROKER_NAME, MQTT_BROKER_MAX_LEN - 1); - mqtt_broker_name[MQTT_BROKER_MAX_LEN - 1] = '\0'; clearExtraTopics(); clearPublishTopics(); clearTrackedKrakes(); clearWatchedTopics(); - applyActiveMqttBrokerConfig(); // PubSubClient uses MQTT over TCP, not WSS. client.setCallback(callback); #if (DEBUG > 0) debugSerial.println("Starting WiFi as STA"); #endif - // Note: On Krake SN#3 only, performing this - // while the Splash is on causes a reset, presumably - // because too much power is drawn. I am using a conditional - // to isolate this as much as possible, while - // still allowing us to use a single code base for all hardware - // devices -- rlr - -#if (LIMIT_POWER_DRAW) - clearLCD(); -#endif - -#if (LIMIT_POWER_DRAW) - splashLCD(); -#endif - // setup_spi(); uint8_t mac[6]; @@ -2223,7 +2340,7 @@ void setup() publish_Default_Topic[MAX_TOPIC_LEN - 1] = '\0'; loadMqttConfig(); - applyActiveMqttBrokerConfig(); + applyMqttBrokerConfig(); #if (DEBUG > 1) debugSerial.println("XXXXXXX"); @@ -2241,7 +2358,7 @@ void setup() // clearLCD(); // req for Wifi Man and OTA -#if defined HMWK || defined KRAKE +#if defined(KRAKE) wifiManager.setConnectedCallback(handleWifiConnected); #endif @@ -2272,7 +2389,10 @@ void setup() #endif - initRotator(); + if (!initRotator()) + { + debugSerial.println(F("Warning: rotary encoder unavailable; continuing setup.")); + } #if (DEBUG > 0) debugSerial.println(F("initRotator")); #endif @@ -2304,15 +2424,39 @@ void setup() bool running_menu = false; bool menu_just_exited = false; +void serviceWiFiReconnect() +{ +#if defined(KRAKE) + if (WiFi.status() == WL_CONNECTED) + { + return; + } + + if (wifiManager.getMode() != wifi_mode_t::WIFI_MODE_AP && wifiManager.getMode() != wifi_mode_t::WIFI_MODE_APSTA) + { + return; + } + + const unsigned long now = millis(); + if (lastWiFiReconnectAttemptMs != 0 && !millisIntervalElapsed(now, lastWiFiReconnectAttemptMs, WIFI_RECONNECT_INTERVAL_MS)) + { + return; + } + + lastWiFiReconnectAttemptMs = now; + WiFi.reconnect(); +#endif +} + void serviceMqttClient() { -#if defined HMWK || defined KRAKE +#if defined(KRAKE) if (client.connected()) { if (!client.loop()) { #if (DEBUG > 0) - debugSerial.print(mqtt_broker_name); + debugSerial.print(activeMqttBrokerName()); debugSerial.print(" lost MQTT at: "); debugSerial.println(millis()); #endif @@ -2335,10 +2479,10 @@ void serviceMqttClient() void serviceRuntimeDiagnostics() { -#if ENABLE_DEBUG_LOGS +#if DEBUG_LEVEL >= 2 static unsigned long lastDiagnosticsMs = 0; const unsigned long now = millis(); - if (lastDiagnosticsMs != 0 && (now - lastDiagnosticsMs) < 10000) + if (lastDiagnosticsMs != 0 && !millisIntervalElapsed(now, lastDiagnosticsMs, 10000)) { return; } @@ -2361,14 +2505,13 @@ void serviceRuntimeDiagnostics() void serviceDeferredReset() { -#if defined HMWK || defined KRAKE - if (wifiResetRequestedAtMs != 0 && (millis() - wifiResetRequestedAtMs) > 750) +#if defined(KRAKE) + if (wifiResetRequestedAtMs != 0 && millisIntervalElapsed(millis(), wifiResetRequestedAtMs, 750)) { debugSerial.println(F("Resetting WiFi credentials and restarting.")); - if (LittleFS.exists("/wifi.json")) + if (!wifiManager.forgetSavedCredentials()) { - LittleFS.remove("/wifi.json"); - debugSerial.println(F("Removed /wifi.json")); + debugSerial.println(F("Warning: failed to clear all stored WiFi credentials.")); } WiFi.disconnect(true, true); delay(150); @@ -2383,7 +2526,7 @@ void serviceHeapDiagnostics() static unsigned long lastHeapDiagnosticMs = 0; const unsigned long HEAP_DIAGNOSTIC_INTERVAL_MS = 30000; const unsigned long now = millis(); - if (lastHeapDiagnosticMs == 0 || (now - lastHeapDiagnosticMs) >= HEAP_DIAGNOSTIC_INTERVAL_MS) + if (lastHeapDiagnosticMs == 0 || millisIntervalElapsed(now, lastHeapDiagnosticMs, HEAP_DIAGNOSTIC_INTERVAL_MS)) { lastHeapDiagnosticMs = now; debugSerial.print(F("Free heap: ")); @@ -2398,6 +2541,7 @@ void loop() { // MQTT gets the first and most frequent slices so inbound bursts are drained // before comparatively slow LCD/menu/audio work is serviced. + serviceWiFiReconnect(); serviceMqttClient(); serviceDeferredReset(); @@ -2407,11 +2551,11 @@ void loop() GPAD_HAL_loop(); serviceMqttClient(); - processSerial(&debugSerial, &debugSerial, &client); + processSerial(&debugSerial, &debugSerial); serviceMqttClient(); // Here we also process the UART1 using the same routine. - processSerial(&debugSerial, &uartSerial1, &client); + processSerial(&debugSerial, &uartSerial1); serviceMqttClient(); // Here we will listen for an SPI command... @@ -2436,7 +2580,7 @@ void loop() serviceMqttClient(); } -#if defined HMWK || defined KRAKE +#if defined(KRAKE) publishOnLineMsg(); serviceHeapDiagnostics(); serviceRuntimeDiagnostics(); diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp index 941800f8..d06f6a9b 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp +++ b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp @@ -25,8 +25,8 @@ #include #include "WiFiManagerOTA.h" #include "GPAD_menu.h" -#include "mqtt_handler.h" #include "debug_macros.h" +#include "setup_status.h" #include #include #include @@ -155,16 +155,50 @@ const char *resetReasonToString(esp_reset_reason_t reason); HardwareSerial uartSerial1(1); // For user Serial Port HardwareSerial uartSerial2(2); // For DFPLayer, audio -#include -// Time in ms you need to hold down the button to be considered a long press -unsigned int longPressTime = 1000; -// How many times you need to hit the button to be considered a multi-hit -byte multiHitTarget = 2; -// How fast you need to hit all buttons to be considered a multi-hit -unsigned int multiHitTime = 400; +namespace +{ +constexpr unsigned long BUTTON_DEBOUNCE_MS = 35; +class DebouncedButton +{ +public: + typedef void (*PressedHandler)(); + void begin(uint8_t pin, PressedHandler handler) + { + _pin = pin; + _handler = handler; + _rawPressed = digitalRead(_pin) == LOW; + _stablePressed = _rawPressed; + _changedAt = millis(); + } + void poll() + { + const bool rawPressed = digitalRead(_pin) == LOW; + const unsigned long now = millis(); + if (rawPressed != _rawPressed) + { + _rawPressed = rawPressed; + _changedAt = now; + } + if (_stablePressed != _rawPressed && millisIntervalElapsed(now, _changedAt, BUTTON_DEBOUNCE_MS)) + { + _stablePressed = _rawPressed; + if (_stablePressed && _handler != nullptr) + { + _handler(); + } + } + } +private: + uint8_t _pin = 0; + PressedHandler _handler = nullptr; + bool _rawPressed = false; + bool _stablePressed = false; + unsigned long _changedAt = 0; +}; -DailyStruggleButton muteButton; -DailyStruggleButton encoderSwitchButton; +DebouncedButton muteButton; +DebouncedButton encoderSwitchButton; +} extern const char *AlarmNames[]; extern AlarmLevel currentLevel; @@ -177,14 +211,14 @@ extern char macAddressString[13]; extern int muteTimeoutMinutes; extern char currentAlarmId[11]; extern char currentAlarmType[4]; -extern PubSubClient client; -extern char mqtt_broker_name[]; -extern uint8_t selectedBrokerIndex; -extern uint8_t activeBrokerIndex; +extern bool mqttClientConnected(); extern uint8_t mqttFailCount; +extern const char *activeMqttBrokerLabel(); +extern const char *connectedMqttBroker(); +extern bool customMqttBrokerConfigured(); +extern bool selectMqttBrokerProfile(uint8_t profile, bool persist); extern const char *mqttStateDescription(int state); extern const char *brokerConnectionStateText(); -extern bool selectMqttBrokerOption(uint8_t index); // For LCD // #include @@ -300,7 +334,9 @@ namespace uint8_t lcdPageOption = 0; char actionFeedbackText[LCD_COLS + 1] = ""; unsigned long actionFeedbackStartMs = 0; + unsigned long lastLcdUiInteractionMs = 0; const unsigned long ACTION_FEEDBACK_DURATION_MS = 2500; + const unsigned long LCD_FRAME_INACTIVITY_TIMEOUT_MS = 120000; char previousLcdRows[LCD_ROWS][LCD_COLS + 1] = { " ", " ", @@ -338,9 +374,18 @@ namespace void setLcdUiState(LcdUiState state) { lcdUiState = state; + if (state != MAIN_PAGE) + { + lastLcdUiInteractionMs = millis(); + } markLcdDirty(); } + bool lcdFrameInactivityTimedOut(unsigned long now) + { + return lcdUiState != MAIN_PAGE && millisIntervalElapsed(now, lastLcdUiInteractionMs, LCD_FRAME_INACTIVITY_TIMEOUT_MS); + } + bool alarmIsActive() { return currentLevel != silent; @@ -708,7 +753,7 @@ namespace } const unsigned long now = millis(); - if ((now - muteTimeoutEndMillis) < 0x80000000UL) + if (millisDeadlineReached(now, muteTimeoutEndMillis)) { return 0; } @@ -748,7 +793,7 @@ namespace const char *mqttStatusText() { - if (client.connected()) + if (mqttClientConnected()) { return "Connected"; } @@ -810,7 +855,7 @@ namespace uint8_t brokerStatusIcon() { - if (client.connected()) + if (mqttClientConnected()) { return 'B'; } @@ -818,7 +863,7 @@ namespace { return '_'; } - return (mqttFailCount > 0 || activeBrokerIndex != selectedBrokerIndex) ? '?' : '_'; + return mqttFailCount > 0 ? '?' : '_'; } uint8_t volumeStatusIcon() @@ -910,7 +955,7 @@ namespace void renderRows(char rows[LCD_ROWS][LCD_COLS + 1]) { const unsigned long now = millis(); - if (!lcdDirty && lastLcdRenderMs != 0 && (now - lastLcdRenderMs) < LCD_RENDER_MIN_INTERVAL_MS) + if (!lcdDirty && lastLcdRenderMs != 0 && !millisIntervalElapsed(now, lastLcdRenderMs, LCD_RENDER_MIN_INTERVAL_MS)) { return; } @@ -958,7 +1003,7 @@ namespace } else if (lcdPage == PAGE_BROKER) { - optionRow = (lcdPageOption == 0) ? 1 : (lcdPageOption == 1 ? 2 : 3); + optionRow = (lcdPageOption == 0) ? 1 : 3; } else if (lcdPage == PAGE_MUTE) { @@ -990,12 +1035,18 @@ namespace } } - bool isDue(const unsigned long now, const unsigned long lastRun, const unsigned long interval) + bool isDue(const uint32_t now, const uint32_t lastRun, const uint32_t interval) { - return lastRun == 0 || (now - lastRun) >= interval; + return lastRun == 0 || millisIntervalElapsed(now, lastRun, interval); } } +void cancelPendingAlarmAudio() +{ + alarmAudioUpdatePending = false; + pendingAlarmAudioLevel = silent; +} + // in general, we want tones to last forever, although // I may implement blinking later. const unsigned long INF_DURATION = 4294967295; @@ -1015,14 +1066,6 @@ volatile boolean process; byte received_signal_raw_bytes[MAX_BUFFER_SIZE]; -// Local DEBUG defines, GPAD_HAL -#define DEBUG 0 -// #define DEBUG 1 - -#if (DEBUG > 0) -Serial.println("Debug defined >0"); -#endif - void setup_spi() { Serial.println(F("Starting SPI Peripheral.")); @@ -1057,11 +1100,7 @@ void setup_spi() const int SPI_BYTE_TIMEOUT_MS = 200; // we don't get the next byte this fast, we reset. volatile unsigned long last_byte_ms = 0; -#if defined(HMWK) -// void IRAM_ATTR ISR() { -// receive_byte(SPDR); -// } -#elif defined(GPAD) // compile for an UNO, for example... +#if defined(GPAD) // compile for an UNO, for example... ISR(SPI_STC_vect) // Inerrrput routine function { receive_byte(SPDR); @@ -1123,121 +1162,34 @@ void updateFromSPI() } } -// Have to get a serialport here - -// void myCallback(byte buttonEvent) { -void encoderSwitchCallback(byte buttonEvent) +void encoderSwitchPressed() { - switch (buttonEvent) + DBG_VERBOSE_PRINTLN(F("ENCODER_SWITCH pressed")); + if (running_menu) { - case onPress: - // Do something... - local_ptr_to_serial->println(F("ENCODER_SWITCH onPress")); - // currentlyMuted = !currentlyMuted; - // start_of_song = millis(); - // annunciateAlarmLevel(local_ptr_to_serial); - // printAlarmState(local_ptr_to_serial); - - if (running_menu) - { - registerRotaryEncoderPress(); - } - else if (!alarmActionSelectorHandlePress()) - { - setLcdUiState(SETTINGS_MENU); - reset_menu_navigation(); - } - break; - case onRelease: - // Do nothing... - local_ptr_to_serial->println(F("ENCODER_SWITCH onRelease")); - break; - case onHold: - // Do nothing... - // local_ptr_to_serial->println(F("ENCODER_SWITCH onHold")); - break; - // onLongPress is indidcated when you hold onto the button - // more than longPressTime in milliseconds - case onLongPress: - DBG_PRINT(F("ENCODER_SWITCH Button Long Pressed For ")); - DBG_PRINT(longPressTime); - DBG_PRINTLN(F("ms")); - returnToMainPage(); - break; - - // onMultiHit is indicated when you hit the button - // multiHitTarget times within multihitTime in milliseconds - case onMultiHit: - DBG_PRINT(F("Encoder Switch Button Pressed ")); - DBG_PRINT(multiHitTarget); - DBG_PRINT(F(" times in ")); - DBG_PRINT(multiHitTime); - DBG_PRINTLN(F("ms")); - break; - default: - DBG_PRINT(F("Encoder Switch buttonEvent but not recognized case: ")); - DBG_PRINTLN(buttonEvent); - break; + registerRotaryEncoderPress(); + } + else if (!alarmActionSelectorHandlePress()) + { + setLcdUiState(SETTINGS_MENU); + reset_menu_navigation(); } } -// Have to get a serialport here -// void myCallback(byte buttonEvent) { -void muteButtonCallback(byte buttonEvent) +void muteButtonPressed() { - switch (buttonEvent) + if (isMuted()) { - case onPress: - // Do something... - local_ptr_to_serial->println(F("SWITCH_MUTE onPress")); - if (isMuted()) - { - setMuted(false); - clearMuteTimeout(); - local_ptr_to_serial->println(F("Manual unmute.")); - } - else - { - setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); - local_ptr_to_serial->print(F("Muted for ")); - local_ptr_to_serial->print(muteTimeoutMinutes); - local_ptr_to_serial->println(F(" minute(s).")); - - } - start_of_song = millis(); - requestAlarmRefresh(local_ptr_to_serial); - printAlarmState(local_ptr_to_serial); - break; - case onRelease: - // Do nothing... - local_ptr_to_serial->println(F("SWITCH_MUTE onRelease")); - break; - case onHold: - // Do nothing... - local_ptr_to_serial->println(F("SWITCH_MUTE onHold")); - break; - // onLongPress is indidcated when you hold onto the button - // more than longPressTime in milliseconds - case onLongPress: - DBG_PRINT(F("SWITCH_MUTE Long Pressed For ")); - DBG_PRINT(longPressTime); - DBG_PRINTLN(F("ms")); - break; - - // onMultiHit is indicated when you hit the button - // multiHitTarget times within multihitTime in milliseconds - case onMultiHit: - DBG_PRINT(F("Button Pressed ")); - DBG_PRINT(multiHitTarget); - DBG_PRINT(F(" times in ")); - DBG_PRINT(multiHitTime); - DBG_PRINTLN(F("ms")); - break; - default: - DBG_PRINT(F("Mute buttonEvent but not recognized case: ")); - DBG_PRINTLN(buttonEvent); - break; + setMuted(false); + clearMuteTimeout(); } + else + { + setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); + } + start_of_song = millis(); + requestAlarmRefresh(local_ptr_to_serial); + printAlarmState(local_ptr_to_serial); } void GPAD_HAL_setup(Stream *serialport, wifi_mode_t wifiMode, IPAddress &deviceIp) @@ -1287,23 +1239,19 @@ void GPAD_HAL_setup(Stream *serialport, wifi_mode_t wifiMode, IPAddress &deviceI } serialport->println(""); - muteButton.set(SWITCH_MUTE, muteButtonCallback); - muteButton.enableLongPress(longPressTime); - muteButton.enableMultiHit(multiHitTime, multiHitTarget); - - // SW4.set(GPIO_SW4, SendEmergMessage, INT_PULL_UP); - // encoderSwitchButton.set(SWITCH_ENCODER, encoderSwitchCallback, INT_PULL_UP); - encoderSwitchButton.set(SWITCH_ENCODER, encoderSwitchCallback); - encoderSwitchButton.enableLongPress(longPressTime); - encoderSwitchButton.enableMultiHit(multiHitTime, multiHitTarget); + muteButton.begin(SWITCH_MUTE, muteButtonPressed); + encoderSwitchButton.begin(SWITCH_ENCODER, encoderSwitchPressed); printInstructions(serialport); AlarmMessageBuffer[0] = '\0'; // digitalWrite(LED_BUILTIN, LOW); // turn the LED off at end of setup -#if !defined(HMWK) // On Homework2, LCD goes blank early - comPrefs.begin(COM_PREF_NS, false); + if (!comPrefs.begin(COM_PREF_NS, false)) + { + setSetupError(SETUP_ERROR_COM_PREFERENCES); + serialport->println(F("Warning: COM preferences unavailable; using defaults.")); + } comPortConfig.baudRate = loadComBaudRate(); comPortConfig.serialFormatIndex = loadComSerialFormatIndex(); comPortConfig.flowControl = loadComFlowControl(); @@ -1312,7 +1260,6 @@ void GPAD_HAL_setup(Stream *serialport, wifi_mode_t wifiMode, IPAddress &deviceI #if (DEBUG > 0) serialport->println(F("uartSerial1 Setup")); -#endif #endif // Here initialize the UART2 @@ -1324,10 +1271,8 @@ void GPAD_HAL_setup(Stream *serialport, wifi_mode_t wifiMode, IPAddress &deviceI #endif } // end GPAD_HAL_setup() -// This routine should be refactored so that it only "interprets" -// the character buffer and returns an "abstract" command to be acted on -// elseshere. This will allow us to remove the PubSubClient from the this file, -// the Hardware Abstraction Layer. +// Interpret transport-independent commands. The caller applies returned +// transport actions such as MQTT acknowledgements or restart requests. class CharBufferPrint : public Print { @@ -1357,12 +1302,16 @@ class CharBufferPrint : public Print size_t _pos; }; -void printAndPublishStatusLine(Stream *serialport, PubSubClient *mqttClient, const char *line) +void reportStatusLine(Stream *serialport, SystemInfoLineHandler lineHandler, const char *line) { - serialport->println(line != nullptr ? line : ""); - if (mqttClient != nullptr && mqttClient->connected()) + const char *safeLine = line != nullptr ? line : ""; + if (serialport != nullptr) + { + serialport->println(safeLine); + } + if (lineHandler != nullptr) { - publishAck(mqttClient, line); + lineHandler(safeLine); } } @@ -1379,55 +1328,65 @@ void formatUptime(char *dest, size_t destLen) snprintf(dest, destLen, "%02lu:%02lu:%02lu", hours, minutes, seconds); } -void printSystemInfo(Stream *serialport, PubSubClient *mqttClient) +void printSystemInfo(Stream *serialport, SystemInfoLineHandler lineHandler) { char value[80]; - printAndPublishStatusLine(serialport, mqttClient, "=== KRAKE SYSTEM INFO ==="); + reportStatusLine(serialport, lineHandler, "=== KRAKE SYSTEM INFO ==="); snprintf(value, sizeof(value), "KRAKE-%.6s", macAddressString + 6); char line[128]; snprintf(line, sizeof(line), "SN: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); snprintf(line, sizeof(line), "FW: %s", FIRMWARE_VERSION); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); ipAddressText(value, sizeof(value)); snprintf(line, sizeof(line), "IP: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); snprintf(line, sizeof(line), "MAC: %s", macAddressString); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); currentSsid(value, sizeof(value)); snprintf(line, sizeof(line), "SSID: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); - snprintf(line, sizeof(line), "Broker: %s", mqtt_broker_name); - printAndPublishStatusLine(serialport, mqttClient, line); - snprintf(line, sizeof(line), "MQTT: %s", brokerConnectionStateText()); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Broker profile: %s", activeMqttBrokerLabel()); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "MQTT: %s (%s)", brokerConnectionStateText(), connectedMqttBroker()); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Connected broker: %s", connectedMqttBroker()); + reportStatusLine(serialport, lineHandler, line); snprintf(line, sizeof(line), "Heap: %lu", static_cast(ESP.getFreeHeap())); - printAndPublishStatusLine(serialport, mqttClient, line); + reportStatusLine(serialport, lineHandler, line); formatUptime(value, sizeof(value)); snprintf(line, sizeof(line), "Uptime: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); - printAndPublishStatusLine(serialport, mqttClient, "========================="); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Reset reason: %s", resetReasonToString(esp_reset_reason())); + reportStatusLine(serialport, lineHandler, line); + formatSetupErrors(value, sizeof(value)); + snprintf(line, sizeof(line), "Setup errors: %s", value); + reportStatusLine(serialport, lineHandler, line); + reportStatusLine(serialport, lineHandler, "========================="); } void showStatusLCD(AlarmLevel level, bool muted, char *msg); -void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *client) +InterpretedCommand interpretBuffer(char *buf, int rlen, Stream *serialport) { if (buf == nullptr || serialport == nullptr || rlen < 1) { + cancelPendingAlarmAudio(); if (serialport != nullptr) { printError(serialport); } - return; // no action + return {}; // no action } const char command = buf[0]; if (command == '\0') { + cancelPendingAlarmAudio(); printError(serialport); - return; + return {}; } + InterpretedCommand result; serialport->print(F("Command: ")); serialport->printf("%c\n", command); @@ -1437,12 +1396,14 @@ void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *clie { serialport->println(F("Muting Case!")); setMuted(true); + result.includeAudioRefresh = true; break; } case 'u': { serialport->println(F("UnMuting Case!")); setMuted(false); + result.includeAudioRefresh = true; break; } case 'h': @@ -1457,8 +1418,9 @@ void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *clie if (gpMessage.getMessageType() != gpap_message::MessageType::ALARM) { serialport->println(F("GPAP alarm parse failed.")); + cancelPendingAlarmAudio(); printError(serialport); - return; + return {}; } const auto &alarmMessage = gpMessage.getAlarmMessage(); @@ -1485,8 +1447,9 @@ void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *clie if (N < 0 || N >= NUM_LEVELS) { serialport->println(F("Invalid GPAP alarm severity.")); + cancelPendingAlarmAudio(); printError(serialport); - return; + return {}; } char msg[MAX_BUFFER_SIZE]; @@ -1500,31 +1463,39 @@ void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *clie serialport->println(msg); alarm((AlarmLevel)N, msg, serialport); + if (N == silent) + { + // GPAP a0 is an informational message: retain and display its content, + // but cancel stale deferred audio and never schedule new playback. + cancelPendingAlarmAudio(); + serialport->println(F("GPAP informational message received; audio playback skipped.")); + } + else + { + result.includeAudioRefresh = true; + } break; } case 'I': case 'i': { - printSystemInfo(serialport, client); + printSystemInfo(serialport); + result.publishSystemInfo = true; break; } case 'R': case 'r': { serialport->println(F("Software reset requested.")); - if (client != nullptr && client->connected()) - { - publishAck(client, "Software reset requested."); - } showActionFeedback("System restarting"); - requestAlarmRefresh(serialport, false); showStatusLCD(currentLevel, currentlyMuted, AlarmMessageBuffer); - delay(100); - ESP.restart(); + result.publishResetAck = true; + result.restartRequested = true; break; } default: { + cancelPendingAlarmAudio(); printError(serialport); break; } @@ -1532,7 +1503,7 @@ void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *clie serialport->print(F("currentlyMuted : ")); serialport->println(currentlyMuted); serialport->println(F("interpret Done")); - // FLE delay(3000); + return result; } // end interpretBuffer() void muteTimeoutWatchdog(Stream *serialport) @@ -1578,7 +1549,7 @@ void serviceAlarmUiAudio(Stream *serialport) const unsigned long settleMs = (alarmUiPendingRequestCount >= ALARM_UI_BURST_REQUEST_COUNT) ? ALARM_UI_BURST_SETTLE_MS : ALARM_UI_NORMAL_SETTLE_MS; - if ((now - lastAlarmUiRequestMs) < settleMs) + if (!millisIntervalElapsed(now, lastAlarmUiRequestMs, settleMs)) { return; } @@ -1619,14 +1590,16 @@ void GPAD_HAL_loop() { muteButton.poll(); encoderSwitchButton.poll(); -#if defined(GPAD) // FLE??? Why is this conditional compile? - muteButton.poll(); -#endif muteTimeoutWatchdog(local_ptr_to_serial); static unsigned long lastDashboardRefreshMs = 0; const unsigned long now = millis(); - if (lcdUiState == ACTION_FEEDBACK && (now - actionFeedbackStartMs) >= ACTION_FEEDBACK_DURATION_MS) + if (lcdFrameInactivityTimedOut(now)) + { + DBG_PRINTLN(F("LCD frame inactivity timeout. Returning to main page.")); + returnToMainPage(); + } + if (lcdUiState == ACTION_FEEDBACK && millisIntervalElapsed(now, actionFeedbackStartMs, ACTION_FEEDBACK_DURATION_MS)) { actionFeedbackText[0] = '\0'; setLcdUiState(MAIN_PAGE); @@ -1684,6 +1657,11 @@ void noteLcdQueueMessageReceived() markLcdDirty(); } +void noteLcdUiInteraction() +{ + lastLcdUiInteractionMs = millis(); +} + void resetLcdUiToMainPage() { lcdPage = PAGE_MAIN; @@ -1777,6 +1755,11 @@ void executeSelectedAlarmAction() bool alarmActionSelectorHandleRotation(bool clockwise) { + noteLcdUiInteraction(); + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } if (lcdUiState == INFO_PAGE && lcdPage == PAGE_INFO) { if (clockwise) @@ -1887,6 +1870,11 @@ bool alarmActionSelectorHandleRotation(bool clockwise) bool alarmActionSelectorHandlePress() { + noteLcdUiInteraction(); + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } if (lcdUiState == INFO_PAGE) { returnToMainPage(); @@ -1913,12 +1901,12 @@ bool alarmActionSelectorHandlePress() if (lcdPageOption == 0) { resetLcdUiToMainPage(); - showActionFeedback(selectMqttBrokerOption(1) ? "Broker selected" : "Broker failed"); + showActionFeedback(selectMqttBrokerProfile(0, true) ? "Krake broker" : "Save failed"); } else if (lcdPageOption == 1) { resetLcdUiToMainPage(); - showActionFeedback(selectMqttBrokerOption(0) ? "Broker selected" : "Broker failed"); + showActionFeedback(selectMqttBrokerProfile(1, true) ? "Custom broker" : "Set custom on web"); } else { @@ -2005,6 +1993,10 @@ bool alarmActionSelectorHandlePress() { executeSelectedAlarmAction(); } + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } markLcdDirty(); requestAlarmRefresh(local_ptr_to_serial, false); return true; @@ -2031,7 +2023,6 @@ void splashLCD(wifi_mode_t wifiMode, const IPAddress &deviceIp) { lcd.init(); // initialize the lcd // Print a message to the LCD. - // #if (!LIMIT_POWER_DRAW) lcd.backlight(); // #else // lcd.noBacklight(); @@ -2127,9 +2118,9 @@ void renderWifiPage(char rows[LCD_ROWS][LCD_COLS + 1]) void renderBrokerPage(char rows[LCD_ROWS][LCD_COLS + 1]) { - formatFullRow(rows[0], "Broker Select"); - formatFullRow(rows[1], "%c1 Krake PubInv", lcdPageOption == 0 ? '>' : ' '); - formatFullRow(rows[2], "%c2 Public Shiftr", lcdPageOption == 1 ? '>' : ' '); + formatFullRow(rows[0], "Broker:%s", activeMqttBrokerLabel()); + formatFullRow(rows[1], "%cKrake PubInv", lcdPageOption == 0 ? '>' : ' '); + formatFullRow(rows[2], "%cCustom%s", lcdPageOption == 1 ? '>' : ' ', customMqttBrokerConfigured() ? "" : " (web)"); formatFullRow(rows[3], "%cBack", lcdPageOption == 2 ? '>' : ' '); } @@ -2363,7 +2354,6 @@ void unchanged_anunicateAlarmLevel(Stream *serialport) unsigned char light_lvl = LIGHT_LEVEL[currentLevel][note]; set_light_level(light_lvl); // TODO: Change this to our device types -// #if !defined(HMWK) #if defined(GPAD) if (!currentlyMuted) { diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.h b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.h index a87c781d..c97d1f51 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.h +++ b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.h @@ -22,19 +22,10 @@ #define GPAD_HAL_H #include // #include -#include #include #include -// On Nov. 5th, 2024, we image 3 different hardware platforms. -// The GPAD exists, and is working: https://www.hardware-x.com/article/S2468-0672(24)00084-1/fulltext -// The "HMWK2" device exists as an intermediate design, and we are working on this. -// The "Krake" has not yet been designed. -// Note: The GPAD is based on an Arduino UNO, but the HMWK2 and KRAKE goes to ESP32. - -// Define only ONE of these hardware options. -// #define GPAD 1 -// #define HMWK 1 +// This firmware target builds the ESP32 Krake hardware. #define KRAKE 1 // Use these to choose the I2C address of LCD @@ -74,33 +65,11 @@ #define LED_BUILTIN 13 #endif -// This should be done with an "#elif", but I can't get it to work -#if defined(HMWK) - -// #define SWITCH_MUTE 34 -#define SWITCH_MUTE 0 // Boot button -#define LED_D9 23 -#define LIGHT0 15 -#define LIGHT1 4 -#define LIGHT2 5 -#define LIGHT3 18 -#define LIGHT4 19 -// The HMWK use a dev kit LED -#define LED_BUILTIN 2 - -#endif - #ifdef GPAD_VERSION1 // The Version 1 PCB. // #define SS 7 // nCS aka /SS Input on GPAD Version 1 PCB. -#if defined(HMWK) -// const int LED_D9 = 23; // Mute1 LED on PMD -#define LED_PIN 23 // for GPAD LIGHT0 -#define BUTTON_PIN 2 // GPAD Button to GND, 10K Resistor to +5V. -#else // compile for an UNO, for example... #define LED_PIN PD3 // for GPAD LIGHT0 #define BUTTON_PIN PD2 // GPAD Button to GND, 10K Resistor to +5V. -#endif #else // The proof of concept wiring. #define LED_PIN 7 @@ -326,6 +295,7 @@ void receive_byte(byte c); void updateFromSPI(); void restoreAlarmLevel(Stream *serialport); +void cancelPendingAlarmAudio(); void requestAlarmRefresh(Stream *serialport, bool includeAudio = true); void showMainLcdFrameNow(Stream *serialport); void unchanged_anunicateAlarmLevel(Stream *serialport); @@ -343,10 +313,21 @@ void showWifiStatusPage(); const char *lcdUiStateName(); void drawLcdStatusIconsNow(); void noteLcdQueueMessageReceived(); +void noteLcdUiInteraction(); void clearLCD(void); void splashLCD(wifi_mode_t wifiMode, const IPAddress &deviceIp); -void interpretBuffer(char *buf, int rlen, Stream *serialport, PubSubClient *client); +struct InterpretedCommand +{ + bool includeAudioRefresh = false; + bool publishSystemInfo = false; + bool publishResetAck = false; + bool restartRequested = false; +}; + +typedef void (*SystemInfoLineHandler)(const char *line); +void printSystemInfo(Stream *serialport, SystemInfoLineHandler lineHandler = nullptr); +InterpretedCommand interpretBuffer(char *buf, int rlen, Stream *serialport); // This module has to be initialized and called each time through the superloop void GPAD_HAL_setup(Stream *serialport, wifi_mode_t wifiMode, IPAddress &deviceIp); diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp b/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp index e529bd30..15aa3cf7 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp +++ b/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp @@ -7,23 +7,37 @@ #include "RickmanLiquidCrystal_I2C.h" #include "DFPlayer.h" #include "alarm_api.h" -#include "mqtt_handler.h" #include "debug_macros.h" +#include "gpad_utility.h" +#include "operator_settings.h" using namespace Menu; -extern PubSubClient client; +extern bool publishAlarmAction(const char *responseType, const char *alarmId); extern char currentAlarmId[11]; extern bool running_menu; extern bool menu_just_exited; extern unsigned long muteTimeoutEndMillis; -extern bool selectMqttBrokerOption(uint8_t index); +extern bool selectMqttBrokerProfile(uint8_t profile, bool persist); #define LEDPIN 12 #define MAX_DEPTH 3 static bool settingsExitRequested = false; +static unsigned long lastMenuInteractionMs = 0; +const unsigned long MENU_INACTIVITY_TIMEOUT_MS = 120000; + +void noteMenuInteraction() +{ + lastMenuInteractionMs = millis(); + noteLcdUiInteraction(); +} + +bool menuInactivityTimedOut() +{ + return millisIntervalElapsed(millis(), lastMenuInteractionMs, MENU_INACTIVITY_TIMEOUT_MS); +} void reset_menu_navigation(); static void finishReturnToMainPage(); @@ -45,11 +59,11 @@ result action1(eventMask e) if (e == eventMask::enterEvent) { DBG_PRINTLN(F("Acknowledging alarm")); + publishAlarmAction("a", currentAlarmId); + DBG_PRINT(F("GPAP response queued for ID: ")); + DBG_PRINTLN(currentAlarmId); + requestAlarmRefresh(&Serial, false); } - publishGPAPResponse(&client, "a", currentAlarmId); - DBG_PRINT(F("GPAP response queued for ID: ")); - DBG_PRINTLN(currentAlarmId); - requestAlarmRefresh(&Serial, false); return proceed; } result action2(eventMask e) @@ -57,13 +71,13 @@ result action2(eventMask e) if (e == eventMask::enterEvent) { DBG_PRINTLN(F("Dismissing alarm")); + char emptyMsg[] = ""; + alarm(silent, emptyMsg, &Serial); // sets currentLevel=0, clears AlarmMessageBuffer + requestAlarmRefresh(&Serial); // coalesces LCD/audio updates from loop() + publishAlarmAction("d", currentAlarmId); + DBG_PRINT(F("GPAP response queued for ID: ")); + DBG_PRINTLN(currentAlarmId); } - char emptyMsg[] = ""; - alarm(silent, emptyMsg, &Serial); // sets currentLevel=0, clears AlarmMessageBuffer - requestAlarmRefresh(&Serial); // coalesces LCD/audio updates from loop() - publishGPAPResponse(&client, "d", currentAlarmId); - DBG_PRINT(F("GPAP response queued for ID: ")); - DBG_PRINTLN(currentAlarmId); return proceed; } result action3(eventMask e) @@ -71,13 +85,13 @@ result action3(eventMask e) if (e == eventMask::enterEvent) { DBG_PRINTLN(F("Shelving alarm")); + char emptyMsg[] = ""; + alarm(silent, emptyMsg, &Serial); + requestAlarmRefresh(&Serial); + publishAlarmAction("s", currentAlarmId); + DBG_PRINT(F("GPAP response queued for ID: ")); + DBG_PRINTLN(currentAlarmId); } - char emptyMsg[] = ""; - alarm(silent, emptyMsg, &Serial); - requestAlarmRefresh(&Serial); - publishGPAPResponse(&client, "s", currentAlarmId); - DBG_PRINT(F("GPAP response queued for ID: ")); - DBG_PRINTLN(currentAlarmId); return proceed; } result action4(eventMask e) @@ -85,22 +99,13 @@ result action4(eventMask e) if (e == eventMask::enterEvent) { DBG_PRINTLN(F("Saving volume")); + DBG_PRINT(F("volume value: ")); + DBG_PRINTLN(volumeDFPlayer); + setVolume(volumeDFPlayer); + saveVolumeSetting(volumeDFPlayer); } - DBG_PRINT(F("volume value: ")); - DBG_PRINTLN(volumeDFPlayer); - setVolume(volumeDFPlayer); return proceed; } -// result action5(eventMask e) -// { -// if (e & eventMask::enterEvent) -// { -// DBG_PRINTLN(F("exiting menu")); -// returnToMainPage(); -// return proceed; -// } -// return proceed; -// } result action5(eventMask e) { if (e & eventMask::enterEvent) @@ -109,7 +114,7 @@ result action5(eventMask e) char emptyMsg[] = ""; alarm(silent, emptyMsg, &Serial); requestAlarmRefresh(&Serial); - publishGPAPResponse(&client, "c", currentAlarmId); + publishAlarmAction("c", currentAlarmId); DBG_PRINT(F("GPAP response queued for ID: ")); DBG_PRINTLN(currentAlarmId); } @@ -224,6 +229,7 @@ result actionMuteTimeout(eventMask e) DBG_PRINT(F("Mute timeout set: ")); DBG_PRINT(muteTimeoutMinutes); DBG_PRINTLN(F(" min")); + saveMuteTimeoutMinutesSetting(muteTimeoutMinutes); requestAlarmRefresh(&Serial); } return proceed; @@ -233,6 +239,7 @@ result actionMuteNow(eventMask e) { if (e == eventMask::enterEvent) { + saveMuteTimeoutMinutesSetting(muteTimeoutMinutes); setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); requestAlarmRefresh(&Serial); } @@ -261,32 +268,30 @@ result actionWifiStatus(eventMask e) return proceed; } -bool selectBroker(uint8_t index) -{ - const bool selected = selectMqttBrokerOption(index); - running_menu = false; - menu_just_exited = false; - Menu::doExit(); - resetLcdUiToMainPage(); - showActionFeedback(selected ? "Broker selected" : "Broker failed"); - requestAlarmRefresh(&Serial, false); - return selected; -} - -result actionBrokerPublic(eventMask e) +result actionBrokerKrake(eventMask e) { if (e == eventMask::enterEvent) { - selectBroker(0); + running_menu = false; + menu_just_exited = false; + Menu::doExit(); + resetLcdUiToMainPage(); + showActionFeedback(selectMqttBrokerProfile(0, true) ? "Krake broker" : "Save failed"); + requestAlarmRefresh(&Serial, false); } return proceed; } -result actionBrokerKrake(eventMask e) +result actionBrokerCustom(eventMask e) { if (e == eventMask::enterEvent) { - selectBroker(1); + running_menu = false; + menu_just_exited = false; + Menu::doExit(); + resetLcdUiToMainPage(); + showActionFeedback(selectMqttBrokerProfile(1, true) ? "Custom broker" : "Set custom on web"); + requestAlarmRefresh(&Serial, false); } return proceed; } @@ -335,8 +340,8 @@ MENU(wifiMenu, "WiFi", Menu::doNothing, Menu::noEvent, Menu::wrapStyle, ); MENU(brokerMenu, "Broker", Menu::doNothing, Menu::noEvent, Menu::wrapStyle, - OP("1 Krake PubInv", actionBrokerKrake, enterEvent), - OP("2 Public Shiftr", actionBrokerPublic, enterEvent), + OP("Krake PubInv", actionBrokerKrake, enterEvent), + OP("Saved Custom", actionBrokerCustom, enterEvent), OP("Back", actionBack, enterEvent) ); @@ -357,7 +362,7 @@ MENU(developerMenu, "Developer Mode", Menu::doNothing, Menu::noEvent, Menu::wrap MENU(mainMenu, "Settings", Menu::doNothing, Menu::noEvent, Menu::wrapStyle, OP("Info", actionInfo, enterEvent), SUBMENU(wifiMenu), - FIELD(volumeDFPlayer, "Volume", "%", 1, 30, 20, 1, action4, enterEvent, wrapStyle), + FIELD(volumeDFPlayer, "Volume", "%", 1, 100, 20, 1, action4, enterEvent, wrapStyle), SUBMENU(muteMenu), SUBMENU(developerMenu), SUBMENU(resetConfirmMenu), @@ -386,6 +391,7 @@ NAVROOT(nav, mainMenu, MAX_DEPTH, in, out); void registerRotationEvent(bool CW) { + noteMenuInteraction(); DBG_PRINT(F("CW: ")); DBG_PRINTLN(CW); reIn.registerEvent(CW ? RotaryEventIn::EventType::ROTARY_CW @@ -394,6 +400,7 @@ void registerRotationEvent(bool CW) void registerRotaryEncoderPress() { + noteMenuInteraction(); reIn.registerEvent(RotaryEventIn::EventType::BUTTON_CLICKED); } @@ -407,6 +414,13 @@ void setup_GPAD_menu() void poll_GPAD_menu() { + if (running_menu && menuInactivityTimedOut()) + { + DBG_PRINTLN(F("Menu inactivity timeout. Returning to main page.")); + finishReturnToMainPage(); + return; + } + nav.poll(); if (settingsExitRequested) { @@ -445,6 +459,7 @@ void open_settings_menu_at(int n) void reset_menu_navigation() { running_menu = true; + noteMenuInteraction(); nav.reset(); } diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_menu.h b/Firmware/GPAD_API/GPAD_API/GPAD_menu.h index 8de947c4..809b8848 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_menu.h +++ b/Firmware/GPAD_API/GPAD_API/GPAD_menu.h @@ -3,6 +3,8 @@ void setup_GPAD_menu(); +extern int muteTimeoutMinutes; + void poll_GPAD_menu(); void navigate_to_n_and_execute(int n); @@ -10,6 +12,8 @@ void open_settings_menu_at(int n); void registerRotationEvent(bool CW); void registerRotaryEncoderPress(); +void noteMenuInteraction(); +bool menuInactivityTimedOut(); void reset_menu_navigation(); void returnToMainPage(); diff --git a/Firmware/GPAD_API/GPAD_API/InterruptRotator.cpp b/Firmware/GPAD_API/GPAD_API/InterruptRotator.cpp index a9586644..9efc2c8d 100644 --- a/Firmware/GPAD_API/GPAD_API/InterruptRotator.cpp +++ b/Firmware/GPAD_API/GPAD_API/InterruptRotator.cpp @@ -2,6 +2,8 @@ #include "GPAD_HAL.h" #include "GPAD_menu.h" #include "debug_macros.h" +#include "setup_status.h" +#include static RotaryEncoder *encoder = nullptr; volatile unsigned long rotaryEncoderEventCount = 0; @@ -11,22 +13,31 @@ volatile unsigned long rotaryEncoderEventCount = 0; // information (false) extern bool running_menu; -void initRotator() +bool initRotator() { - // Serial.begin(115200); - // while (!Serial); DBG_PRINTLN(F("InterruptRotator init")); - encoder = new RotaryEncoder(PIN_IN1, PIN_IN2, RotaryEncoder::LatchMode::TWO03); + encoder = new (std::nothrow) RotaryEncoder(PIN_IN1, PIN_IN2, RotaryEncoder::LatchMode::TWO03); + if (encoder == nullptr) + { + setSetupError(SETUP_ERROR_ROTARY_ENCODER); + DBG_PRINTLN(F("Rotary encoder allocation failed; continuing without encoder input.")); + return false; + } attachInterrupt(digitalPinToInterrupt(PIN_IN1), checkPositionISR, CHANGE); attachInterrupt(digitalPinToInterrupt(PIN_IN2), checkPositionISR, CHANGE); + return true; } void updateRotator() { static int pos = 0; + if (encoder == nullptr) + { + return; + } encoder->tick(); int newPos = encoder->getPosition(); @@ -38,9 +49,6 @@ void updateRotator() DBG_PRINT(newPos); DBG_PRINT(F(" dir: ")); int d = (int)(encoder->getDirection()); - // Serial.println(d); - - // int d = (int)(encoder->getDirection()); bool CW; if (d == (int)RotaryEncoder::Direction::CLOCKWISE) CW = true; @@ -54,11 +62,6 @@ void updateRotator() return; } - // Serial.print("d : "); - // Serial.println(d); - // Serial.println((int) RotaryEncoder::Direction::CLOCKWISE); - // Serial.println((int) RotaryEncoder::Direction::COUNTERCLOCKWISE); - // Serial.println(CW); registerRotationEvent(CW); pos = newPos; } diff --git a/Firmware/GPAD_API/GPAD_API/InterruptRotator.h b/Firmware/GPAD_API/GPAD_API/InterruptRotator.h index 57d363ac..6eec0d50 100644 --- a/Firmware/GPAD_API/GPAD_API/InterruptRotator.h +++ b/Firmware/GPAD_API/GPAD_API/InterruptRotator.h @@ -14,7 +14,7 @@ constexpr int SW = 34; // Rotary encoder Switch pin #endif // Encoder setup and logic functions -void initRotator(); +bool initRotator(); void updateRotator(); void IRAM_ATTR checkPositionISR(); diff --git a/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.cpp b/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.cpp index 3354f230..9a2f557d 100644 --- a/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.cpp +++ b/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.cpp @@ -1,9 +1,53 @@ #include "WiFiManagerOTA.h" #include +#include +#include "gpad_utility.h" namespace { const char *WIFI_CREDENTIALS_PATH = "/wifi.json"; + const char *WIFI_PREFERENCES_NAMESPACE = "krake-wifi"; + const char *WIFI_PREFERENCES_KEY = "networks"; + const unsigned long WIFI_PORTAL_TIMEOUT_SECONDS = 60; + const unsigned long WIFI_STORED_CREDENTIALS_BUDGET_MS = 15000; + const unsigned long WIFI_STORED_CREDENTIAL_ATTEMPT_MS = 5000; + bool littleFsMounted = false; + + bool saveCredentialsToPreferences(const String &payload) + { + Preferences preferences; + if (!preferences.begin(WIFI_PREFERENCES_NAMESPACE, false)) + { + return false; + } + const size_t written = preferences.putString(WIFI_PREFERENCES_KEY, payload); + preferences.end(); + return written > 0; + } + + String loadCredentialsFromPreferences() + { + Preferences preferences; + if (!preferences.begin(WIFI_PREFERENCES_NAMESPACE, true)) + { + return String(); + } + const String payload = preferences.getString(WIFI_PREFERENCES_KEY, ""); + preferences.end(); + return payload; + } + + bool clearCredentialsFromPreferences() + { + Preferences preferences; + if (!preferences.begin(WIFI_PREFERENCES_NAMESPACE, false)) + { + return false; + } + const bool cleared = !preferences.isKey(WIFI_PREFERENCES_KEY) || preferences.remove(WIFI_PREFERENCES_KEY); + preferences.end(); + return cleared; + } String jsonEscape(const String &value) { @@ -100,6 +144,8 @@ void Manager::initialize() // According to WifiManager's documentation, best practice is still to set the WiFi mode manually // https://github.com/tzapu/WiFiManager/blob/master/examples/Basic/Basic.ino#L5 this->wifi.mode(WIFI_STA); + // Never leave setup blocked forever if credentials were erased or invalid. + this->wifiManager.setConfigPortalTimeout(WIFI_PORTAL_TIMEOUT_SECONDS); } Manager::~Manager() {} @@ -109,8 +155,19 @@ void Manager::connect(const char *const accessPointSsid) CredentialList credentials; if (this->loadCredentialsList(credentials)) { + const unsigned long credentialsStartMs = millis(); for (size_t index = 0; index < credentials.count; ++index) { + const uint32_t elapsedMs = elapsedMillis(millis(), credentialsStartMs); + if (elapsedMs >= WIFI_STORED_CREDENTIALS_BUDGET_MS) + { + this->print.println(F("Stored WiFi retry budget exhausted; starting recovery portal.")); + break; + } + const unsigned long remainingMs = WIFI_STORED_CREDENTIALS_BUDGET_MS - elapsedMs; + const unsigned long attemptMs = (remainingMs < WIFI_STORED_CREDENTIAL_ATTEMPT_MS) + ? remainingMs + : WIFI_STORED_CREDENTIAL_ATTEMPT_MS; #if (DEBUG_LEVEL > 0) this->print.print(F("Trying saved WiFi ")); this->print.print(index + 1); @@ -120,15 +177,16 @@ void Manager::connect(const char *const accessPointSsid) this->print.println(credentials.items[index].ssid); #endif - if (this->connectStoredCredentials(credentials.items[index].ssid, credentials.items[index].password)) + if (this->connectStoredCredentials(credentials.items[index].ssid, credentials.items[index].password, attemptMs)) { - this->print.println(F("Connected using stored wifi.json credentials.")); + this->print.println(F("Connected using stored WiFi credentials.")); this->ipSet(); return; } } } + this->print.println(F("Starting bounded WiFi recovery portal.")); this->startPortal(accessPointSsid); } @@ -172,7 +230,7 @@ void Manager::startPortal(const char *const accessPointSsid) if (!connectSuccess) { - this->print.println(F("WiFiManager portal completed without connection.")); + this->print.println(F("WiFiManager recovery portal timed out; continuing device startup.")); } } @@ -200,11 +258,13 @@ void Manager::startConfigPortal(const char *const accessPointSsid, unsigned long bool Manager::forgetSavedCredentials() { this->wifiManager.resetSettings(); - if (LittleFS.exists(WIFI_CREDENTIALS_PATH)) + const bool preferencesCleared = clearCredentialsFromPreferences(); + bool mirrorCleared = true; + if (littleFsMounted && LittleFS.exists(WIFI_CREDENTIALS_PATH)) { - return LittleFS.remove(WIFI_CREDENTIALS_PATH); + mirrorCleared = LittleFS.remove(WIFI_CREDENTIALS_PATH); } - return true; + return preferencesCleared && mirrorCleared; } IPAddress Manager::getAddress() @@ -247,13 +307,6 @@ bool Manager::saveCredentials(const String &ssid, const String &password) } } - File file = LittleFS.open(WIFI_CREDENTIALS_PATH, "w"); - if (!file) - { - this->print.println(F("Failed to open wifi.json for writing.")); - return false; - } - String payload = "{\"networks\":["; for (size_t i = 0; i < updated.count; ++i) { @@ -269,9 +322,34 @@ bool Manager::saveCredentials(const String &ssid, const String &password) } payload += "]}"; - const size_t written = file.print(payload); - file.close(); - return written == payload.length(); + if (!saveCredentialsToPreferences(payload)) + { + this->print.println(F("Failed to save WiFi credentials to NVS.")); + return false; + } + + // Keep a LittleFS mirror for backward compatibility and diagnostics. The NVS + // copy remains authoritative because uploading a filesystem image erases this file. + if (littleFsMounted) + { + File file = LittleFS.open(WIFI_CREDENTIALS_PATH, "w"); + if (!file) + { + this->print.println(F("Warning: failed to mirror WiFi credentials to wifi.json.")); + } + else + { + file.print(payload); + file.close(); + } + } + return true; +} + +bool Manager::hasSavedCredentials() +{ + return loadCredentialsFromPreferences().length() > 0 || + (littleFsMounted && LittleFS.exists(WIFI_CREDENTIALS_PATH)); } bool Manager::loadCredentials(String &ssid, String &password) @@ -290,21 +368,27 @@ bool Manager::loadCredentials(String &ssid, String &password) bool Manager::loadCredentialsList(CredentialList &credentials) { credentials.count = 0; - if (!LittleFS.exists(WIFI_CREDENTIALS_PATH)) + String content = loadCredentialsFromPreferences(); + if (content.length() == 0 && littleFsMounted && LittleFS.exists(WIFI_CREDENTIALS_PATH)) { - return false; + File file = LittleFS.open(WIFI_CREDENTIALS_PATH, "r"); + if (!file) + { + this->print.println(F("Failed to open wifi.json for reading.")); + return false; + } + content = file.readString(); + file.close(); + if (content.length() > 0) + { + saveCredentialsToPreferences(content); + } } - - File file = LittleFS.open(WIFI_CREDENTIALS_PATH, "r"); - if (!file) + if (content.length() == 0) { - this->print.println(F("Failed to open wifi.json for reading.")); return false; } - const String content = file.readString(); - file.close(); - int searchPos = 0; while (credentials.count < MAX_SAVED_WIFI_NETWORKS) { @@ -367,7 +451,7 @@ bool Manager::loadCredentialsList(CredentialList &credentials) String legacyPassword; if (!extractJsonString(content, "ssid", legacySsid) || !extractJsonString(content, "password", legacyPassword)) { - this->print.println(F("wifi.json missing required keys.")); + this->print.println(F("Stored WiFi credentials are missing required keys.")); return false; } @@ -375,7 +459,7 @@ bool Manager::loadCredentialsList(CredentialList &credentials) legacyPassword.trim(); if (legacySsid.length() == 0 || legacyPassword.length() == 0) { - this->print.println(F("wifi.json has invalid SSID/password.")); + this->print.println(F("Stored WiFi credentials have an invalid SSID/password.")); return false; } @@ -386,13 +470,13 @@ bool Manager::loadCredentialsList(CredentialList &credentials) bool Manager::connectStoredCredentials(const String &ssid, const String &password, unsigned long timeoutMs) { #if (DEBUG_LEVEL > 0) - this->print.print(F("Attempting connection from wifi.json SSID: ")); + this->print.print(F("Attempting connection using saved WiFi SSID: ")); this->print.println(ssid); #endif this->wifi.begin(ssid.c_str(), password.c_str()); const unsigned long startMs = millis(); - while ((millis() - startMs) < timeoutMs) + while (!millisIntervalElapsed(millis(), startMs, timeoutMs)) { if (this->wifi.status() == WL_CONNECTED) { @@ -401,7 +485,7 @@ bool Manager::connectStoredCredentials(const String &ssid, const String &passwor delay(250); } - this->print.println(F("Stored wifi.json credentials failed to connect.")); + this->print.println(F("Stored WiFi credentials failed to connect.")); this->wifi.disconnect(true, false); return false; } @@ -447,18 +531,64 @@ void Manager::apStarted() } } -void WifiOTA::initLittleFS() +bool WifiOTA::initLittleFS() { - if (!LittleFS.begin(true)) + littleFsMounted = LittleFS.begin(false); + if (!littleFsMounted) { - Serial.println(F("An error occurred while mounting LittleFS.")); + Serial.println(F("LittleFS mount failed. Filesystem unavailable; refusing to auto-format.")); + Serial.println(F("Upload the LittleFS image or format explicitly during service.")); + return false; } - else + + printLittleFSDiagnostics(Serial); + return true; +} + +bool WifiOTA::isLittleFSMounted() +{ + return littleFsMounted; +} + +void WifiOTA::printLittleFSDiagnostics(Print &print) +{ + print.println(F("=== LittleFS diagnostics ===")); + print.print(F("Mounted: ")); + print.println(littleFsMounted ? F("yes") : F("no")); + if (!littleFsMounted) { -#if (DEBUG > 1) - Serial.println("LittleFS mounted successfully."); -#endif + print.println(F("============================")); + return; + } + + print.print(F("Total bytes: ")); + print.println(LittleFS.totalBytes()); + print.print(F("Used bytes: ")); + print.println(LittleFS.usedBytes()); + + File root = LittleFS.open("/"); + if (!root || !root.isDirectory()) + { + print.println(F("Unable to list filesystem root.")); + print.println(F("============================")); + return; + } + + File file = root.openNextFile(); + if (!file) + { + print.println(F("Filesystem root is empty. Upload the LittleFS image.")); + } + while (file) + { + print.print(F(" ")); + print.print(file.name()); + print.print(F(" (")); + print.print(file.size()); + print.println(F(" bytes)")); + file = root.openNextFile(); } + print.println(F("============================")); } String WifiOTA::processor(const String &var) diff --git a/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.h b/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.h index 08887592..0315e103 100644 --- a/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.h +++ b/Firmware/GPAD_API/GPAD_API/WiFiManagerOTA.h @@ -41,6 +41,7 @@ namespace WifiOTA bool saveCredentials(const String &ssid, const String &password); bool loadCredentials(String &ssid, String &password); bool loadCredentialsList(CredentialList &credentials); + bool hasSavedCredentials(); private: WiFiClass &wifi; @@ -56,7 +57,9 @@ namespace WifiOTA void startPortal(const char *const accessPointSsid); }; - void initLittleFS(); + bool initLittleFS(); + bool isLittleFSMounted(); + void printLittleFSDiagnostics(Print &print); String processor(const String &var); }; diff --git a/Firmware/GPAD_API/GPAD_API/Wink.cpp b/Firmware/GPAD_API/GPAD_API/Wink.cpp index 85b7433d..3077f1f9 100644 --- a/Firmware/GPAD_API/GPAD_API/Wink.cpp +++ b/Firmware/GPAD_API/GPAD_API/Wink.cpp @@ -6,12 +6,13 @@ // Heart beat aka activity indicator LED. // Set LED for Uno or ESP32 Dev Kit on board blue LED. #include +#include "gpad_utility.h" // Wink the LED void wink(void) { // TODO - const int LED_BUILTIN = 13; // ESP32 Kit//const int LED_BUILTIN = 2; HWK2 // Not really needed for Arduino UNO it is defined in library + const int LED_BUILTIN = 13; // Krake ESP32 activity LED pinMode(LED_BUILTIN, OUTPUT); // const int HIGH_TIME_LED = 900; // const int LOW_TIME_LED = 100; @@ -19,7 +20,8 @@ void wink(void) const int LOW_TIME_LED = 500; static unsigned long lastLEDtime = 0; static unsigned long nextLEDchange = 500; // time in ms. - if (((millis() - lastLEDtime) > nextLEDchange) || (millis() < lastLEDtime)) + const uint32_t now = millis(); + if (millisIntervalElapsed(now, lastLEDtime, nextLEDchange)) { if (digitalRead(LED_BUILTIN) == LOW) { @@ -31,6 +33,6 @@ void wink(void) digitalWrite(LED_BUILTIN, LOW); // turn the LED on (HIGH is the voltage level) nextLEDchange = LOW_TIME_LED; } - lastLEDtime = millis(); + lastLEDtime = now; } } // end LED wink diff --git a/Firmware/GPAD_API/GPAD_API/alarm_api.cpp b/Firmware/GPAD_API/GPAD_API/alarm_api.cpp index f6fd3c85..baf9e621 100644 --- a/Firmware/GPAD_API/GPAD_API/alarm_api.cpp +++ b/Firmware/GPAD_API/GPAD_API/alarm_api.cpp @@ -87,7 +87,9 @@ void setMuteTimeoutMinutes(unsigned long minutes) { setMuted(true); muteTimeoutStartMillis = millis(); - muteTimeoutDurationMillis = minutes * 60000UL; + const unsigned long maxSafeMinutes = MILLIS_MAX_SAFE_INTERVAL_MS / 60000UL; + const unsigned long safeMinutes = minutes > maxSafeMinutes ? maxSafeMinutes : minutes; + muteTimeoutDurationMillis = safeMinutes * 60000UL; muteTimeoutEndMillis = muteTimeoutStartMillis + muteTimeoutDurationMillis; } @@ -105,7 +107,7 @@ bool serviceMuteTimeout() return false; } - if ((millis() - muteTimeoutStartMillis) >= muteTimeoutDurationMillis) + if (millisIntervalElapsed(millis(), muteTimeoutStartMillis, muteTimeoutDurationMillis)) { clearMuteTimeout(); setMuted(false); diff --git a/Firmware/GPAD_API/GPAD_API/debug_macros.h b/Firmware/GPAD_API/GPAD_API/debug_macros.h index 4846a8ea..e8db4ddb 100644 --- a/Firmware/GPAD_API/GPAD_API/debug_macros.h +++ b/Firmware/GPAD_API/GPAD_API/debug_macros.h @@ -3,36 +3,40 @@ #include +// Serial-monitor verbosity: 0 = errors only, 1 = operational messages, +// 2 = verbose diagnostics. Override with -DDEBUG_LEVEL=<0|1|2>. #ifndef DEBUG_LEVEL #define DEBUG_LEVEL 0 #endif +#if DEBUG_LEVEL < 0 || DEBUG_LEVEL > 2 +#error "DEBUG_LEVEL must be 0, 1, or 2" +#endif +#ifndef DEBUG +#define DEBUG DEBUG_LEVEL +#endif +#ifndef GPAD_DEBUG +#define GPAD_DEBUG DEBUG_LEVEL +#endif #ifndef ENABLE_LCD_UI #define ENABLE_LCD_UI 1 #endif - -#ifndef ENABLE_DEBUG_LOGS -#define ENABLE_DEBUG_LOGS 0 -#endif - #ifndef ENABLE_DFPLAYER #define ENABLE_DFPLAYER 1 #endif - #ifndef ENABLE_COM_SETUP #define ENABLE_COM_SETUP 1 #endif - #ifndef ENABLE_OTA #define ENABLE_OTA 1 #endif -#ifndef GPAD_DEBUG -#define GPAD_DEBUG DEBUG_LEVEL -#endif - -#define DBG_PRINT(x) do { if (ENABLE_DEBUG_LOGS || (GPAD_DEBUG > 0)) { Serial.print(x); } } while (0) -#define DBG_PRINTLN(x) do { if (ENABLE_DEBUG_LOGS || (GPAD_DEBUG > 0)) { Serial.println(x); } } while (0) -#define DBG_PRINTF(...) do { if (ENABLE_DEBUG_LOGS || (GPAD_DEBUG > 0)) { Serial.printf(__VA_ARGS__); } } while (0) +#define DBG_AT(level, statement) do { if (DEBUG_LEVEL >= (level)) { statement; } } while (0) +#define DBG_PRINT(x) DBG_AT(1, Serial.print(x)) +#define DBG_PRINTLN(x) DBG_AT(1, Serial.println(x)) +#define DBG_PRINTF(...) DBG_AT(1, Serial.printf(__VA_ARGS__)) +#define DBG_VERBOSE_PRINT(x) DBG_AT(2, Serial.print(x)) +#define DBG_VERBOSE_PRINTLN(x) DBG_AT(2, Serial.println(x)) +#define DBG_VERBOSE_PRINTF(...) DBG_AT(2, Serial.printf(__VA_ARGS__)) #endif diff --git a/Firmware/GPAD_API/GPAD_API/gpad_serial.cpp b/Firmware/GPAD_API/GPAD_API/gpad_serial.cpp index b4fc23a9..be093050 100644 --- a/Firmware/GPAD_API/GPAD_API/gpad_serial.cpp +++ b/Firmware/GPAD_API/GPAD_API/gpad_serial.cpp @@ -23,6 +23,8 @@ #include "alarm_api.h" #include "GPAD_HAL.h" #include "debug_macros.h" + +extern void applyInterpretedCommand(const InterpretedCommand &result, Stream *serialport); // #include extern bool currentlyMuted; @@ -42,7 +44,7 @@ where C is an character, and D is a single digit. // Note: The buffer "buf" used here might be more safely made // a parameter passed in from the caller. -void processSerial(Stream *debugPort, Stream *inputPort, PubSubClient *client) +void processSerial(Stream *debugPort, Stream *inputPort) { if (debugPort == nullptr || inputPort == nullptr) { @@ -83,8 +85,8 @@ void processSerial(Stream *debugPort, Stream *inputPort, PubSubClient *client) if (rlen > 0) { - interpretBuffer(buf, rlen, debugPort, client); - requestAlarmRefresh(debugPort); + const InterpretedCommand result = interpretBuffer(buf, rlen, debugPort); + applyInterpretedCommand(result, debugPort); printAlarmState(debugPort); processedCommand = true; } @@ -100,6 +102,7 @@ void processSerial(Stream *debugPort, Stream *inputPort, PubSubClient *client) { // Overflow guard: reset buffer if a line grows too long. writeIndex = 0; + cancelPendingAlarmAudio(); printError(debugPort); processedCommand = true; } diff --git a/Firmware/GPAD_API/GPAD_API/gpad_serial.h b/Firmware/GPAD_API/GPAD_API/gpad_serial.h index a0faa117..660f3c82 100644 --- a/Firmware/GPAD_API/GPAD_API/gpad_serial.h +++ b/Firmware/GPAD_API/GPAD_API/gpad_serial.h @@ -21,8 +21,7 @@ #ifndef GPAD_SERIAL #define GPAD_SERIAL 1 #include -#include -void processSerial(Stream *debugPort, Stream *inputPort, PubSubClient *client); +void processSerial(Stream *debugPort, Stream *inputPort); #endif diff --git a/Firmware/GPAD_API/GPAD_API/gpad_utility.h b/Firmware/GPAD_API/GPAD_API/gpad_utility.h index 62e5ef50..a3ed5673 100644 --- a/Firmware/GPAD_API/GPAD_API/gpad_utility.h +++ b/Firmware/GPAD_API/GPAD_API/gpad_utility.h @@ -21,6 +21,7 @@ #ifndef GPAD_UTILITY #define GPAD_UTILITY 1 #include +#include #ifndef COMPANY_NAME #define COMPANY_NAME "" #endif @@ -42,9 +43,39 @@ #endif #define DEVICE_UNDER_TEST "Krake: DFPlayer" // This is GPAD code, but if it is used in testing... -// THIS IS FOR DEBUGGING -// #define LIMIT_POWER_DRAW 1 //FLE on 20260119 -#define LIMIT_POWER_DRAW 0 +// Arduino millis() is a wrapping 32-bit counter. Keep intervals within half +// its range and compare elapsed time with unsigned subtraction so rollover is +// handled without special cases. +const uint32_t MILLIS_MAX_SAFE_INTERVAL_MS = 0x7FFFFFFFUL; +inline uint32_t elapsedMillis(uint32_t now, uint32_t startedAt) +{ + return now - startedAt; +} +inline bool millisIntervalElapsed(uint32_t now, uint32_t startedAt, uint32_t intervalMs) +{ + return elapsedMillis(now, startedAt) >= intervalMs; +} +inline bool millisDeadlineReached(uint32_t now, uint32_t deadline) +{ + return static_cast(now - deadline) >= 0; +} + +// Arduino millis() is a wrapping 32-bit counter. Keep intervals within half +// its range and compare elapsed time with unsigned subtraction so rollover is +// handled without special cases. +const uint32_t MILLIS_MAX_SAFE_INTERVAL_MS = 0x7FFFFFFFUL; +inline uint32_t elapsedMillis(uint32_t now, uint32_t startedAt) +{ + return now - startedAt; +} +inline bool millisIntervalElapsed(uint32_t now, uint32_t startedAt, uint32_t intervalMs) +{ + return elapsedMillis(now, startedAt) >= intervalMs; +} +inline bool millisDeadlineReached(uint32_t now, uint32_t deadline) +{ + return static_cast(now - deadline) >= 0; +} void printError(Stream *serialport); void printInstructions(Stream *serialport); diff --git a/Firmware/GPAD_API/GPAD_API/operator_settings.cpp b/Firmware/GPAD_API/GPAD_API/operator_settings.cpp new file mode 100644 index 00000000..12064871 --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/operator_settings.cpp @@ -0,0 +1,56 @@ +#include "operator_settings.h" +#include +#include "setup_status.h" + +namespace +{ + const char *OPERATOR_PREF_NS = "operator"; + const char *OPERATOR_PREF_VOLUME = "volume"; + const char *OPERATOR_PREF_MUTE_MIN = "muteMinutes"; + + int clampSetting(int value, int minimum, int maximum) + { + if (value < minimum) return minimum; + if (value > maximum) return maximum; + return value; + } + + bool saveUChar(const char *key, int value) + { + Preferences prefs; + if (!prefs.begin(OPERATOR_PREF_NS, false)) { setSetupError(SETUP_ERROR_OPERATOR_PREFERENCES); return false; } + const size_t written = prefs.putUChar(key, static_cast(value)); + prefs.end(); + return written == sizeof(uint8_t); + } +} + +int loadVolumeSetting(int fallbackPercent) +{ + Preferences prefs; + if (!prefs.begin(OPERATOR_PREF_NS, true)) { setSetupError(SETUP_ERROR_OPERATOR_PREFERENCES); return clampSetting(fallbackPercent, OPERATOR_VOLUME_MIN_PERCENT, OPERATOR_VOLUME_MAX_PERCENT); } + const int safeFallback = clampSetting(fallbackPercent, OPERATOR_VOLUME_MIN_PERCENT, OPERATOR_VOLUME_MAX_PERCENT); + const int volume = prefs.getUChar(OPERATOR_PREF_VOLUME, static_cast(safeFallback)); + prefs.end(); + return clampSetting(volume, OPERATOR_VOLUME_MIN_PERCENT, OPERATOR_VOLUME_MAX_PERCENT); +} + +int loadMuteTimeoutMinutesSetting(int fallbackMinutes) +{ + Preferences prefs; + if (!prefs.begin(OPERATOR_PREF_NS, true)) { setSetupError(SETUP_ERROR_OPERATOR_PREFERENCES); return clampSetting(fallbackMinutes, OPERATOR_MUTE_TIMEOUT_MIN_MINUTES, OPERATOR_MUTE_TIMEOUT_MAX_MINUTES); } + const int safeFallback = clampSetting(fallbackMinutes, OPERATOR_MUTE_TIMEOUT_MIN_MINUTES, OPERATOR_MUTE_TIMEOUT_MAX_MINUTES); + const int minutes = prefs.getUChar(OPERATOR_PREF_MUTE_MIN, static_cast(safeFallback)); + prefs.end(); + return clampSetting(minutes, OPERATOR_MUTE_TIMEOUT_MIN_MINUTES, OPERATOR_MUTE_TIMEOUT_MAX_MINUTES); +} + +bool saveVolumeSetting(int volumePercent) +{ + return saveUChar(OPERATOR_PREF_VOLUME, clampSetting(volumePercent, OPERATOR_VOLUME_MIN_PERCENT, OPERATOR_VOLUME_MAX_PERCENT)); +} + +bool saveMuteTimeoutMinutesSetting(int minutes) +{ + return saveUChar(OPERATOR_PREF_MUTE_MIN, clampSetting(minutes, OPERATOR_MUTE_TIMEOUT_MIN_MINUTES, OPERATOR_MUTE_TIMEOUT_MAX_MINUTES)); +} diff --git a/Firmware/GPAD_API/GPAD_API/operator_settings.h b/Firmware/GPAD_API/GPAD_API/operator_settings.h new file mode 100644 index 00000000..09dbfcff --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/operator_settings.h @@ -0,0 +1,14 @@ +#ifndef OPERATOR_SETTINGS_H +#define OPERATOR_SETTINGS_H + +const int OPERATOR_VOLUME_MIN_PERCENT = 1; +const int OPERATOR_VOLUME_MAX_PERCENT = 100; +const int OPERATOR_MUTE_TIMEOUT_MIN_MINUTES = 1; +const int OPERATOR_MUTE_TIMEOUT_MAX_MINUTES = 60; + +int loadVolumeSetting(int fallbackPercent); +int loadMuteTimeoutMinutesSetting(int fallbackMinutes); +bool saveVolumeSetting(int volumePercent); +bool saveMuteTimeoutMinutesSetting(int minutes); + +#endif diff --git a/Firmware/GPAD_API/GPAD_API/setup_status.cpp b/Firmware/GPAD_API/GPAD_API/setup_status.cpp new file mode 100644 index 00000000..cbcabfe6 --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/setup_status.cpp @@ -0,0 +1,53 @@ +#include "setup_status.h" +#include +#include + +namespace +{ +uint32_t errors = SETUP_ERROR_NONE; + +void appendError(char *dest, size_t destLen, const char *text) +{ + if (destLen == 0 || text == nullptr) + { + return; + } + const size_t used = strlen(dest); + if (used >= destLen - 1) + { + return; + } + snprintf(dest + used, destLen - used, "%s%s", used == 0 ? "" : ", ", text); +} +} + +void setSetupError(SetupErrorFlag error) +{ + errors |= static_cast(error); +} + +uint32_t setupErrorFlags() +{ + return errors; +} + +void formatSetupErrors(char *dest, size_t destLen) +{ + if (destLen == 0) + { + return; + } + dest[0] = '\0'; + if (errors == SETUP_ERROR_NONE) + { + snprintf(dest, destLen, "none"); + return; + } + if (errors & SETUP_ERROR_LITTLEFS) appendError(dest, destLen, "LittleFS mount"); + if (errors & SETUP_ERROR_DFPLAYER) appendError(dest, destLen, "DFPlayer unavailable"); + if (errors & SETUP_ERROR_DFPLAYER_FILES) appendError(dest, destLen, "DFPlayer audio files"); + if (errors & SETUP_ERROR_ROTARY_ENCODER) appendError(dest, destLen, "rotary encoder allocation"); + if (errors & SETUP_ERROR_COM_PREFERENCES) appendError(dest, destLen, "COM preferences"); + if (errors & SETUP_ERROR_OPERATOR_PREFERENCES) appendError(dest, destLen, "operator preferences"); + if (errors & SETUP_ERROR_MQTT_PREFERENCES) appendError(dest, destLen, "MQTT preferences"); +} diff --git a/Firmware/GPAD_API/GPAD_API/setup_status.h b/Firmware/GPAD_API/GPAD_API/setup_status.h new file mode 100644 index 00000000..278d912c --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/setup_status.h @@ -0,0 +1,22 @@ +#ifndef SETUP_STATUS_H +#define SETUP_STATUS_H + +#include + +enum SetupErrorFlag : uint32_t +{ + SETUP_ERROR_NONE = 0, + SETUP_ERROR_LITTLEFS = 1UL << 0, + SETUP_ERROR_DFPLAYER = 1UL << 1, + SETUP_ERROR_DFPLAYER_FILES = 1UL << 2, + SETUP_ERROR_ROTARY_ENCODER = 1UL << 3, + SETUP_ERROR_COM_PREFERENCES = 1UL << 4, + SETUP_ERROR_OPERATOR_PREFERENCES = 1UL << 5, + SETUP_ERROR_MQTT_PREFERENCES = 1UL << 6, +}; + +void setSetupError(SetupErrorFlag error); +uint32_t setupErrorFlags(); +void formatSetupErrors(char *dest, size_t destLen); + +#endif diff --git a/Firmware/GPAD_API/VERSIONING.md b/Firmware/GPAD_API/VERSIONING.md new file mode 100644 index 00000000..1d933a1f --- /dev/null +++ b/Firmware/GPAD_API/VERSIONING.md @@ -0,0 +1,25 @@ +# GPAD firmware versioning + +The GPAD firmware version is stored in `FIRMWARE_VERSION` as a Semantic Versioning +2.0.0 value. PlatformIO reads that file from `pre_extra_script.py` and exposes it +to the firmware as the `FIRMWARE_VERSION` macro. + +After a pull request is merged into `main`, the `Increment GPAD firmware version` +workflow increments the patch component and records the merged pull request +number as SemVer build metadata. For example, merging PR `#123` after `0.58.0` +produces `0.58.1+pr.123`. + +Each merged pull request runs its own optimistic update. If multiple merges or a +direct update advance `main` at the same time, a failed push retries from the +latest branch state and increments that newer version. The workflow checks the +version-file commit history before changing anything, so rerunning an older PR's +workflow remains idempotent even after newer PRs have incremented the version. + +For an intentional release, update `FIRMWARE_VERSION` in a pull request to the +desired `MAJOR.MINOR.PATCH` baseline. The next merged pull request resumes patch +increments from that baseline. + +If `main` is protected against direct pushes, configure the repository rules so +this GitHub Actions workflow may push its isolated version commit. If that is +not allowed, the workflow intentionally fails after its retry limit instead of +silently leaving the firmware version stale. diff --git a/Firmware/GPAD_API/data/PMD_GPAD_API.html b/Firmware/GPAD_API/data/PMD_GPAD_API.html index 3d9a019f..21079dd8 100644 --- a/Firmware/GPAD_API/data/PMD_GPAD_API.html +++ b/Firmware/GPAD_API/data/PMD_GPAD_API.html @@ -11,7 +11,7 @@
-
Program: PMD_Processing_MQTT (V0.37)
Role: Krake
Broker: public.cloud.shiftr.io
+
Program: PMD_Processing_MQTT (V0.37)
Role: Krake
Broker: Krake PubInv

Issue Alarm (multi-topic)

Select one or more topics from Settings and send PMD alarm payloads to all selected topics.

Selected topics: -
Result: -

Send Message / Alarm

Publish result: -
diff --git a/Firmware/GPAD_API/data/README.md b/Firmware/GPAD_API/data/README.md index e4512652..d2d8b6dd 100644 --- a/Firmware/GPAD_API/data/README.md +++ b/Firmware/GPAD_API/data/README.md @@ -1,6 +1,6 @@ # KRAKE cleaned web bundle -Drop these files into your web/static filesystem. Keep the backend endpoints the same: +Drop these files into the GPAD LittleFS web/static filesystem. Keep the backend endpoints the same: - `/status` - `/lcd` @@ -17,7 +17,13 @@ Main cleanup: - Shared navigation in `js/common.js` - Shared API helpers in `js/common.js` +- Shared browser broker defaults in `js/common.js` +- Browser MQTT connections can select Krake PubInv or a custom broker, stop automatic retries after an authorization rejection, and optionally clear retained alarm messages +- Device MQTT settings persist a selectable Krake PubInv profile or custom broker host, username, and write-only password +- Operator sound defaults persist volume and mute duration across device restarts +- Navigation points directly to the HTML files stored in this bundle - PMD topics are loaded from `/settings-data` - Settings remains the source of truth for MQTT topics - Device monitor stays responsible for online/offline detection -- CSS consolidated into `style.css` +- CSS consolidated into `style.css`, which the firmware serves directly so style fixes are not shadowed by an older compressed companion +- HTTP responses support Chrome private-network preflights and only send gzip companions when the client advertises gzip support diff --git a/Firmware/GPAD_API/data/device-monitor.html b/Firmware/GPAD_API/data/device-monitor.html index f1aac381..91e5f47f 100644 --- a/Firmware/GPAD_API/data/device-monitor.html +++ b/Firmware/GPAD_API/data/device-monitor.html @@ -24,16 +24,18 @@

Online Devices

Shiftr MQTT Broker Monitor

- +

Broker Connection

- - - + + + + +

Enter the selected broker password when required. The monitor stops automatic retries after an authorization rejection so an incorrect password does not flood the event log.

@@ -52,10 +54,11 @@

Publish command to all devices

- + +
-

Publishes to <MAC>_ALM, same as the Processing sketch.

+

Publishes to <MAC>_ALM, same as the Processing sketch. Select clear retained messages to publish an empty retained payload to every tracked alarm topic.

0Online
diff --git a/Firmware/GPAD_API/data/favicon.png.gz b/Firmware/GPAD_API/data/favicon.png.gz deleted file mode 100644 index a24beed4..00000000 Binary files a/Firmware/GPAD_API/data/favicon.png.gz and /dev/null differ diff --git a/Firmware/GPAD_API/data/js/common.js b/Firmware/GPAD_API/data/js/common.js index a3fd5886..97f0d0f8 100644 --- a/Firmware/GPAD_API/data/js/common.js +++ b/Firmware/GPAD_API/data/js/common.js @@ -1,4 +1,11 @@ (function () { + const mqttBroker = Object.freeze({ + hostname: 'krakepubinv.cloud.shiftr.io', + wssUrl: 'wss://krakepubinv.cloud.shiftr.io', + embedUrl: 'https://krakepubinv.cloud.shiftr.io/embed?widgets=1', + username: 'krakepubinv', + profiles: [{ id: 'krake', label: 'Krake PubInv', wssUrl: 'wss://krakepubinv.cloud.shiftr.io', username: 'krakepubinv' }, { id: 'custom', label: 'Custom broker' }] + }); const navSections = [ { id: 'user', @@ -7,7 +14,7 @@ minRole: 'user', items: [ ['GDT Track Record', '/GDT_TrackHistory.html'], - ['User Manual', '/manual'] + ['User Manual', '/manual.html'] ] }, { @@ -16,7 +23,7 @@ defaultOpen: false, minRole: 'admin', items: [ - ['Settings', '/settings'], + ['Settings', '/settings.html'], ['Firmware Update', '/update'] ] }, @@ -26,9 +33,9 @@ defaultOpen: false, minRole: 'developer', items: [ - ['PMD Web UI', '/PMD_GPAD_API'], - ['Factory Test / Developer Monitor', '/monitor'], - ['MQTT Device Monitor', '/device-monitor'], + ['PMD Web UI', '/PMD_GPAD_API.html'], + ['Factory Test / Developer Monitor', '/monitor.html'], + ['MQTT Device Monitor', '/device-monitor.html'], ['Electrical Test History', '/Electrical_testHistory.html'] ] } @@ -86,7 +93,7 @@ ? '
' : ''; - navTarget.innerHTML = sectionHtml + unlockHtml + 'Home'; + navTarget.innerHTML = sectionHtml + unlockHtml + 'Home'; const unlockBtn = byId('devUnlockBtn'); if (unlockBtn) unlockBtn.addEventListener('click', () => byId('devUnlockPanel')?.classList.toggle('hidden')); @@ -110,7 +117,7 @@ const navTarget = byId('sideMenu'); if (headerTarget) { headerTarget.className = 'topbar'; - headerTarget.innerHTML = '
KRAKE icon' + escapeHtml(title || 'KRAKE') + '
'; + headerTarget.innerHTML = '
KRAKE icon' + escapeHtml(title || 'KRAKE') + '
'; } if (navTarget) { navTarget.className = 'side-menu'; renderNav(navTarget); } const menuToggle = byId('menuToggle'); @@ -118,5 +125,5 @@ } function setText(id, value, fallback = '-') { const node = byId(id); if (node) node.textContent = value || fallback; } - window.KrakeUI = { byId, escapeHtml, splitCsv, unique, getPublishTopics, postForm, getJson, showMessage, toggleMenu, mountLayout, setText }; + window.KrakeUI = { mqttBroker, byId, escapeHtml, splitCsv, unique, getPublishTopics, postForm, getJson, showMessage, toggleMenu, mountLayout, setText }; })(); diff --git a/Firmware/GPAD_API/data/js/device-monitor.js b/Firmware/GPAD_API/data/js/device-monitor.js index 7b4c613a..af97ba42 100644 --- a/Firmware/GPAD_API/data/js/device-monitor.js +++ b/Firmware/GPAD_API/data/js/device-monitor.js @@ -5,6 +5,22 @@ const DEVICE_STORAGE_KEY = 'krakeDeviceMonitor.registry.v2'; let client = null; let messageCount = 0; +function applyBrokerProfile() { + const profile = $('brokerProfile').value; + if (profile === 'krake') { + $('brokerUrl').value = KrakeUI.mqttBroker.wssUrl; + $('username').value = KrakeUI.mqttBroker.username; + } else { + $('brokerUrl').value = ''; + $('username').value = ''; + } + $('password').value = ''; +} +function loadBrokerDefaults() { + $('brokerEmbed').src = KrakeUI.mqttBroker.embedUrl; + applyBrokerProfile(); +} + const defaultDevices = [ ['3C61053EE100', 'PPG_Lee / MinKrakeLeeE100'], ['F024F9F1B874', 'KRAKE_LB0001'], ['142B2FEB1F00', 'KRAKE_LB0002'], ['142B2FEB1C64', 'KRAKE_LB0003'], ['142B2FEB1E24', 'KRAKE_LB0004'], @@ -64,12 +80,12 @@ function log(line) { $('log').textContent = `[${stamp}] ${line}\n` + $('log').textContent; } -function setBrokerStatus(online) { +function setBrokerStatus(online, statusText = online ? 'Broker online' : 'Broker offline', connecting = false) { const el = $('brokerStatus'); - el.textContent = online ? 'Broker online' : 'Broker offline'; + el.textContent = statusText; el.className = `broker ${online ? 'online' : 'offline'}`; - $('connectBtn').disabled = online; - $('disconnectBtn').disabled = !online; + $('connectBtn').disabled = online || connecting; + $('disconnectBtn').disabled = !online && !connecting; $('publishBtn').disabled = !online; } @@ -110,17 +126,35 @@ function render() { function escapeHtml(v) { return String(v).replace(/[&<>'"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch])); } function connect() { - client = mqtt.connect($('brokerUrl').value.trim(), { + const brokerUrl = $('brokerUrl').value.trim(); + const password = $('password').value; + if (!brokerUrl) { + log('Broker WSS URL is required before connecting.'); + $('brokerUrl').focus(); + return; + } + if (!password && $('brokerProfile').value === 'krake') { + log('Krake PubInv broker password is required before connecting.'); + $('password').focus(); + return; + } + if (client) client.end(true); + + const nextClient = mqtt.connect(brokerUrl, { clientId: `WebMonitor_${Math.random().toString(16).slice(2)}_${Date.now()}`, - username: $('username').value.trim(), password: $('password').value, + username: $('username').value.trim(), password, clean: true, reconnectPeriod: 2000, connectTimeout: 8000, keepalive: 15 }); - client.on('connect', () => { + let authorizationRejected = false; + client = nextClient; + setBrokerStatus(false, 'Broker connecting', true); + nextClient.on('connect', () => { + if (client !== nextClient) return; setBrokerStatus(true); subscribedMacs.clear(); Object.keys(devices).forEach(subscribeForMac); }); - client.on('message', (topic, payloadBuffer) => { + nextClient.on('message', (topic, payloadBuffer) => { const mac = macFromTopic(topic); if (!mac) return; const d = devices[mac]; @@ -136,9 +170,24 @@ function connect() { messageCount++; render(); }); - client.on('close', () => setBrokerStatus(false)); - client.on('offline', () => setBrokerStatus(false)); - client.on('error', (err) => log(`MQTT error: ${err.message}`)); + nextClient.on('close', () => { + if (client === nextClient && !authorizationRejected) setBrokerStatus(false); + }); + nextClient.on('offline', () => { + if (client === nextClient && !authorizationRejected) setBrokerStatus(false); + }); + nextClient.on('error', (err) => { + const message = err?.message || String(err); + log(`MQTT error: ${message}`); + if (/not authorized|bad user name|authorization/i.test(message)) { + authorizationRejected = true; + nextClient.end(true); + if (client === nextClient) client = null; + subscribedMacs.clear(); + setBrokerStatus(false, 'Broker authorization failed'); + log('Connection stopped. Check the selected broker credentials, then press Connect to retry.'); + } + }); } function disconnect() { if (client) client.end(true); client = null; subscribedMacs.clear(); setBrokerStatus(false); } @@ -168,11 +217,14 @@ function removeDevice(mac) { function publishToAll() { if (!client || !client.connected) return; - const message = $('customCommand').value.trim() || $('commandPreset').value; - const retain = $('retain').checked; + const clearRetained = $('clearRetained').checked; + const message = clearRetained ? '' : ($('customCommand').value.trim() || $('commandPreset').value); + const retain = clearRetained || $('retain').checked; Object.keys(devices).forEach((mac) => client.publish(`${mac}_ALM`, message, { qos: 0, retain })); + log(clearRetained ? 'Queued retained-message clear for all tracked alarm topics.' : `Published command to all tracked alarm topics${retain ? ' with retain enabled' : ''}.`); } +$('brokerProfile').addEventListener('change', applyBrokerProfile); $('connectBtn').addEventListener('click', connect); $('disconnectBtn').addEventListener('click', disconnect); $('publishBtn').addEventListener('click', publishToAll); @@ -182,5 +234,7 @@ $('deviceTable').addEventListener('click', (event) => { if (button) removeDevice(button.dataset.mac); }); +loadBrokerDefaults(); +setBrokerStatus(false); setInterval(render, 1000); render(); diff --git a/Firmware/GPAD_API/data/js/pmd.js b/Firmware/GPAD_API/data/js/pmd.js index 66edb5f1..5a3b751c 100644 --- a/Firmware/GPAD_API/data/js/pmd.js +++ b/Firmware/GPAD_API/data/js/pmd.js @@ -12,7 +12,7 @@ async function loadTopics() { const data = await KrakeUI.getJson('/settings-data'); KrakeUI.setText('deviceRole', data.role || 'Krake'); - KrakeUI.setText('brokerName', data.broker || 'public.cloud.shiftr.io'); + KrakeUI.setText('brokerName', data.broker || KrakeUI.mqttBroker.hostname); const list = KrakeUI.getPublishTopics(data); const select = KrakeUI.byId('topicSelect'); select.innerHTML = ''; diff --git a/Firmware/GPAD_API/data/js/settings.js b/Firmware/GPAD_API/data/js/settings.js index 22b6adf9..fe34da47 100644 --- a/Firmware/GPAD_API/data/js/settings.js +++ b/Firmware/GPAD_API/data/js/settings.js @@ -23,14 +23,27 @@ try { await KrakeUI.postForm('/wifi', { ssid, password }); KrakeUI.showMessage('WiFi credentials saved. Device will retry all saved networks on boot.'); await loadWifi(); } catch (e) { KrakeUI.showMessage('Failed to save WiFi: ' + e.message, true); } } + function syncBrokerProfileFields() { + const custom = getInputValue('brokerProfile') === 'custom'; + ['customBroker', 'customUser', 'customPassword'].forEach((id) => { const node = KrakeUI.byId(id); if (node) node.disabled = !custom; }); + } async function refreshSettings() { const data = await KrakeUI.getJson('/settings-data'); - setInputValue('broker', data.broker || ''); + setInputValue('brokerProfile', data.brokerProfile || 'krake'); + setInputValue('customBroker', data.customBroker || ''); + setInputValue('customUser', data.customUser || ''); + setInputValue('customPassword', ''); + syncBrokerProfileFields(); + KrakeUI.setText('activeBroker', data.broker || KrakeUI.mqttBroker.hostname); + KrakeUI.setText('connectedBroker', data.connectedBroker || 'none'); + KrakeUI.setText('customPasswordStatus', data.customPasswordStored ? 'Stored (write-only)' : 'None'); setInputValue('role', data.role || 'Krake'); setInputValue('topics', data.extraTopics || ''); setInputValue('publishTopics', data.publishTopics || ''); setInputValue('subscribeTopic', data.publishTopic || ''); setInputValue('publishTopic', data.subscribeTopic || ''); + setInputValue('volume', String(data.volume || 20)); + setInputValue('muteTimeoutMinutes', String(data.muteTimeoutMinutes || 5)); KrakeUI.setText('muteStatus', data.muted ? 'Muted' : 'Unmuted'); KrakeUI.setText('alarmTopic', data.publishTopic || '-'); KrakeUI.setText('ackTopic', data.subscribeTopic || '-'); @@ -40,6 +53,14 @@ try { await KrakeUI.postForm('/settings/wifi/reset', {}); KrakeUI.showMessage('WiFi reset started. Device will restart shortly.'); } catch (e) { KrakeUI.showMessage('WiFi reset failed: ' + e.message, true); } } + async function saveSoundSettings() { + const volume = Number(getInputValue('volume')); + const muteTimeoutMinutes = Number(getInputValue('muteTimeoutMinutes')); + if (!Number.isInteger(volume) || volume < 1 || volume > 100) return KrakeUI.showMessage('Volume must be between 1 and 100%.', true); + if (!Number.isInteger(muteTimeoutMinutes) || muteTimeoutMinutes < 1 || muteTimeoutMinutes > 60) return KrakeUI.showMessage('Mute duration must be between 1 and 60 minutes.', true); + try { await KrakeUI.postForm('/settings/sound', { volume, muteTimeoutMinutes }); KrakeUI.showMessage('Sound defaults saved for future restarts.'); await refreshSettings(); } + catch (e) { KrakeUI.showMessage('Failed to save sound defaults: ' + e.message, true); } + } async function setMuted(muted) { try { await KrakeUI.postForm('/settings/mute', { muted: muted ? '1' : '0' }); KrakeUI.showMessage(muted ? 'KRAKE muted.' : 'KRAKE unmuted.'); await refreshSettings(); } catch (e) { KrakeUI.showMessage('Failed to update mute status: ' + e.message, true); } @@ -48,17 +69,24 @@ async function saveMqttConfig() { try { const role = 'Krake'; - const broker = getInputValue('broker').trim(); + const brokerProfile = getInputValue('brokerProfile'); + const customBroker = getInputValue('customBroker').trim(); + const customUser = getInputValue('customUser').trim(); + const customPassword = getInputValue('customPassword'); + if (brokerProfile === 'custom' && !customBroker) return KrakeUI.showMessage('Custom broker host is required.', true); const subscribeTopicUi = getInputValue('subscribeTopic').trim(); const publishTopicUi = getInputValue('publishTopic').trim(); const subscribeTopics = getInputValue('topics'); const publishTopics = getInputValue('publishTopics'); - await KrakeUI.postForm('/config', { role, broker, subscribeTopic: publishTopicUi, publishTopic: subscribeTopicUi, subscribeTopics, publishTopics, publishDefaultTopic: publishTopicUi }); - KrakeUI.showMessage('MQTT config updated and saved to /mqtt.json.'); + const config = { role, brokerProfile, subscribeTopic: publishTopicUi, publishTopic: subscribeTopicUi, subscribeTopics, publishTopics, publishDefaultTopic: publishTopicUi }; + if (brokerProfile === 'custom') Object.assign(config, { customBroker, customUser }); + if (customPassword) config.customPassword = customPassword; + await KrakeUI.postForm('/config', config); + KrakeUI.showMessage('MQTT broker and topic settings updated and saved.'); await refreshSettings(); } catch (e) { KrakeUI.showMessage('Failed to save MQTT config: ' + e.message, true); } } window.saveWifi = saveWifi; window.loadWifi = () => loadWifi().catch(e => KrakeUI.showMessage('Unable to load WiFi settings: ' + e.message, true)); - window.resetWifi = resetWifi; window.setMuted = setMuted; window.saveMqttConfig = saveMqttConfig; + window.resetWifi = resetWifi; window.saveSoundSettings = saveSoundSettings; window.setMuted = setMuted; window.saveMqttConfig = saveMqttConfig; window.syncBrokerProfileFields = syncBrokerProfileFields; Promise.all([loadWifi(), refreshSettings()]).catch(e => KrakeUI.showMessage(e.message, true)); })(); diff --git a/Firmware/GPAD_API/data/krakefav.png b/Firmware/GPAD_API/data/krakefav.png deleted file mode 100644 index e3207268..00000000 Binary files a/Firmware/GPAD_API/data/krakefav.png and /dev/null differ diff --git a/Firmware/GPAD_API/data/settings.html b/Firmware/GPAD_API/data/settings.html index 7acf46ef..c7c08236 100644 --- a/Firmware/GPAD_API/data/settings.html +++ b/Firmware/GPAD_API/data/settings.html @@ -14,7 +14,7 @@

WiFi Setup

-

Save SSID/password to /wifi.json for reconnection on boot. Device will try all saved networks.

+

Save SSID/password for reconnection on boot. Credentials survive LittleFS UI uploads, and the device will try all saved networks.

@@ -42,6 +42,11 @@

Sound

+ + + + +
Current status: -
Mute command topics: -
Last command: -
@@ -55,8 +60,20 @@

MQTT

- - + + +
Active broker: -
+
Connected broker: -
+ + + + + + +
Saved custom password: None
diff --git a/Firmware/GPAD_API/data/style.css b/Firmware/GPAD_API/data/style.css index d9e93990..0ca8d5d3 100644 --- a/Firmware/GPAD_API/data/style.css +++ b/Firmware/GPAD_API/data/style.css @@ -2,212 +2,960 @@ --bg: #f4f8f5; --bg-soft: #e8f2eb; --surface: #ffffff; - --text: #13261d; - --muted: #4f685b; - --line: #cfe3d5; - --accent: #166534; + --text: #1a3225; + --muted: #4f6a5b; + --line: #cee0d3; + --accent: #1f7a3d; + --accent-strong: #fbfcfb; --accent-soft: #e6f3ea; - --danger: #b42318; + --shadow: 0 14px 32px rgba(20, 66, 40, 0.1); } -/* RESET */ -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} body { margin: 0; font-family: Inter, "Segoe UI", Arial, sans-serif; - background: linear-gradient(180deg, var(--bg-soft), var(--bg)); + background: linear-gradient(180deg, var(--bg-soft) 0, var(--bg) 260px, var(--bg) 100%); color: var(--text); } -/* TOPBAR */ .topbar { - height: 64px; - background: linear-gradient(120deg, #4da965, var(--accent)); - color: white; + height: 68px; + background: linear-gradient(120deg, var(--accent-strong), var(--accent)); + color: #1f7a3d; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; + position: sticky; + top: 0; + z-index: 30; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 1.05rem; + letter-spacing: 0.01em; +} + +.brand-icon { + width: 30px; + height: 30px; + object-fit: contain; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + padding: 4px; +} + +.menu-toggle { + background: rgba(255, 255, 255, 0.12); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + font-size: 1.4rem; + width: 42px; + height: 42px; + cursor: pointer; +} + +.side-menu { + position: fixed; + top: 68px; + right: -300px; + width: 280px; + height: calc(100% - 68px); + background: #ffffff; + box-shadow: -8px 0 30px rgba(10, 30, 18, 0.18); + transition: right 0.2s ease; + padding: 12px; + z-index: 25; + display: flex; + flex-direction: column; + gap: 8px; +} + +.side-menu.open { + right: 0; +} + +.side-menu a { + display: block; + padding: 12px 14px; + color: var(--text); + text-decoration: none; + border: 1px solid transparent; + border-radius: 10px; + font-size: 0.96rem; +} + +.side-menu a:hover { + background: var(--accent-soft); + border-color: #d7e7dc; +} + +.side-menu .menu-home { + margin-top: auto; + font-weight: 700; + background: #f1f7f3; + border: 1px solid #d6e5da; +} + +.container { + max-width: 1040px; + margin: 0 auto; + padding: 22px 14px 36px; } -/* CARDS */ .card { background: var(--surface); - border: 1px solid var(--line); border-radius: 16px; + border: 1px solid var(--line); padding: 20px; margin-bottom: 16px; + box-shadow: var(--shadow); } -/* TEXT */ -h1, h2, h3 { - color: var(--text); +h1 { + margin-top: 0; + margin-bottom: 10px; + font-size: clamp(1.45rem, 2.4vw, 1.85rem); + line-height: 1.15; } .subtitle { + margin-top: 0; color: var(--muted); + margin-bottom: 18px; +} + +.info-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.info-row { + margin: 0; + word-break: break-word; + background: #f8fbf9; + border: 1px solid #e0ebe4; + border-radius: 10px; + padding: 10px 12px; } .label { - color: var(--muted); + display: block; font-size: 0.8rem; + letter-spacing: 0.04em; text-transform: uppercase; + color: #4f685b; + margin-bottom: 4px; } -/* INPUTS (FIXED ISSUE HERE) */ -.text-input, -input, -select, -textarea { - width: 100%; +.qr-box { + margin-top: 16px; + display: inline-flex; + flex-direction: column; + gap: 8px; +} + +.qr-box img { + width: 120px; + max-width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: #ffffff; + padding: 5px; +} + +.lcd-title { + font-weight: 700; + margin-bottom: 12px; +} + +.lcd-frame { + background: #1b2320; + display: inline-block; padding: 12px; border-radius: 10px; - border: 1px solid #b9d8c4; - background: #ffffff; /* FIX */ - color: var(--text); /* FIX */ - font-size: 0.95rem; } -input::placeholder, -textarea::placeholder { - color: #6b7f73; - opacity: 1; +.lcd-screen { + background: #a8c97a; + color: #18220f; + padding: 10px 12px; + border-radius: 6px; + font-family: "Courier New", monospace; +} + +.lcd-line { + min-height: 1.4em; + white-space: pre; +} + +.footer { + text-align: center; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + padding: 4px 0 28px; +} + +.footer a { + color: var(--accent-strong); + text-decoration: none; + font-weight: 700; } -/* BUTTONS */ +.footer-btn, .action-btn { + display: inline-block; border: none; border-radius: 10px; padding: 10px 14px; background: var(--accent); - color: #ffffff; + color: #fff !important; font-weight: 700; cursor: pointer; } -.action-btn:hover { - background: #14532d; -} - .action-btn.secondary { - background: #eef7f1; - color: #14532d; - border: 1px solid #b9d8c4; -} - -.action-btn.secondary:hover { - background: #dff1e6; - color: #0f2b1d; + background: #426b54; } .action-btn.danger { - background: var(--danger); - color: #fff; + background: #b00020; } -/* BUTTON ROW */ .button-row { display: flex; - gap: 10px; + gap: 8px; flex-wrap: wrap; } -/* STATUS TEXT */ +.text-input { + width: 100%; + margin-top: 6px; + margin-bottom: 10px; + padding: 10px; + border-radius: 10px; + border: 1px solid #c6d9cd; + background: #ffffff; + color: var(--text); + font-size: 0.95rem; +} + .status-row { margin-top: 8px; - color: var(--muted); + color: #244533; } -/* EMBEDS */ -.embed-wrap { - width: 100%; - overflow-x: auto; +.note { + font-size: 0.95rem; + min-height: 1.2rem; } -.embed-wrap iframe { - display: block; - max-width: 100%; - border: 0; - border-radius: 10px; +@media (max-width: 720px) { + .topbar { height: 62px; } + .side-menu { + top: 62px; + height: calc(100% - 62px); + width: min(88vw, 300px); + } + .container { padding-top: 14px; } } -/* TABLE */ -table { + +.status-table { width: 100%; border-collapse: collapse; + font-size: 0.92rem; } -th { - background: #f1f7f3; - color: var(--muted); - font-size: 0.85rem; +.status-table th, +.status-table td { + border: 1px solid #d7e7dc; + padding: 8px 10px; + text-align: left; + vertical-align: top; } -td, th { - border: 1px solid var(--line); - padding: 10px; +.status-table th { + background: #f1f7f3; + color: #1f4a34; } -/* MENU */ -.side-menu { - position: fixed; - right: -300px; - top: 64px; - width: 280px; - height: calc(100% - 64px); - background: white; - border-left: 1px solid var(--line); - transition: 0.2s; - padding: 12px; -} +/* extracted from device-monitor(2).html */ -.side-menu.open { - right: 0; -} + .hero-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; + } + .eyebrow { + margin: 0; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #5e7a69; + } + .broker { + display: inline-block; + border-radius: 20px; + padding: 6px 10px; + border: 1px solid #d0e2d6; + background: #f8fbf9; + color: #315541; + font-size: .9rem; + font-weight: 700; + white-space: nowrap; + } + .broker.online { background: #e5f6ea; border-color: #b8dec4; color: #146620; } + .broker.offline { background: #fdf0f0; border-color: #ebc8c8; color: #9f2727; } + .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + align-items: end; + } + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + } + .stat { + border: 1px solid #d7e7dc; + border-radius: 12px; + background: #f8fbf9; + padding: 12px; + } + .stat span { + display: block; + font-size: 1.7rem; + font-weight: 800; + color: #18422c; + line-height: 1.1; + } + .stat small { color: #4f685b; } + .table-wrap { overflow-x: auto; } + table { width: 100%; border-collapse: collapse; min-width: 720px; } + th, td { border-bottom: 1px solid #ebf2ee; padding: 10px 8px; text-align: left; vertical-align: top; } + th { color: #4f685b; font-size: .84rem; } + .status-pill { + display: inline-flex; + border-radius: 18px; + border: 1px solid #d7e7dc; + padding: 4px 8px; + font-weight: 700; + font-size: .8rem; + text-transform: capitalize; + } + .status-pill.online { background: #e5f6ea; color: #146620; border-color: #b8dec4; } + .status-pill.offline { background: #fdf0f0; color: #9f2727; border-color: #ebc8c8; } + #log { + margin: 0; + min-height: 170px; + max-height: 300px; + overflow: auto; + background: #0f1a14; + color: #c8f2d3; + border-radius: 10px; + padding: 12px; + white-space: pre-wrap; + } -.side-menu a { - display: block; - padding: 10px; - border-radius: 8px; - color: var(--text); - text-decoration: none; -} -.side-menu a:hover { - background: var(--accent-soft); -} +/* extracted from PMD_GPAD_API(2).html */ -/* STATUS PILLS */ -.status-pill { - padding: 4px 8px; - border-radius: 20px; - font-size: 0.8rem; - font-weight: 700; -} + .console-grid { display: grid; gap: 16px; grid-template-columns: 1fr; } + .status-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 16px; } + .chip { border: 1px solid #d7e7dc; border-radius: 10px; background: #f8fbf9; padding: 9px 11px; font-size: .9rem; color: #315541; } + .chip strong { color: #113521; } + .topic-select { min-height: 140px; } + @media (min-width: 980px) { .console-grid { grid-template-columns: 1fr 1fr; } .console-grid .card-wide { grid-column: 1 / -1; } } -.status-pill.online { - background: #e5f6ea; - color: #146620; -} -.status-pill.offline { - background: #fdecec; - color: #b42318; -} +/* extracted from monitor.html */ -/* LOG BOX */ -#log { - background: #0f1a14; - color: #c8f2d3; - padding: 12px; + .monitor-grid { display: grid; grid-template-columns: 1fr; gap: 16px; } + .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } + .monitor-box { + background: #081217; + color: #b7f9cc; + border: 1px solid rgba(0, 183, 255, .25); + border-radius: 10px; + padding: 14px; + min-height: 280px; + overflow: auto; + white-space: pre-wrap; + line-height: 1.35; + } + .status-chip { + display: inline-block; + background: rgba(0, 183, 255, .12); + border: 1px solid rgba(0, 183, 255, .35); + border-radius: 20px; + padding: 5px 10px; + margin: 4px 6px 0 0; + font-size: 13px; + } + + +/* extracted from Electrical_testHistory.html */ + + :root { + --bg: #f2f8f3; + --surface: #ffffff; + --surface-2: #edf8f0; + --text: #153124; + --muted: #4d6758; + --line: #cfe3d5; + --accent: #1f7a3d; + --accent-soft: #e6f5eb; + --good: #135f31; + --good-bg: #e5f7eb; + --bad: #b42318; + --bad-bg: #fdecec; + --other: #4f6f18; + --other-bg: #f1f8df; + --shadow: 0 14px 34px rgba(24, 74, 44, 0.10); + --radius: 18px; + } + * { box-sizing: border-box; } + body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, #e9f6ed 0%, var(--bg) 240px); + color: var(--text); + } + .page { + max-width: 1280px; + margin: 0 auto; + padding: 28px 20px 60px; + } + .hero { + background: linear-gradient(135deg, #0f5a2f 0%, #1f7a3d 60%, #39a35c 100%); + color: white; + border-radius: 26px; + padding: 28px; + box-shadow: var(--shadow); + margin-bottom: 22px; + } + .eyebrow { + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.9; + margin-bottom: 10px; + } + h1 { + margin: 0 0 10px; + font-size: clamp(1.8rem, 3vw, 2.6rem); + line-height: 1.1; + } + .subtitle { + margin: 0; + max-width: 820px; + color: rgba(255,255,255,.92); + line-height: 1.6; + } + .stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin: 22px 0; + } + .card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + box-shadow: var(--shadow); + } + .stat-card { + background: rgba(255,255,255,.12); + border: 1px solid rgba(255,255,255,.14); + color: white; + backdrop-filter: blur(6px); + } + .stat-label { + font-size: .88rem; + opacity: .9; + margin-bottom: 8px; + } + .stat-value { + font-size: 1.7rem; + font-weight: 700; + } + .controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-bottom: 16px; + } + .search { + flex: 1 1 280px; + min-width: 240px; + border: 1px solid var(--line); + background: var(--surface); + border-radius: 14px; + padding: 14px 16px; + font-size: 0.98rem; + color: var(--text); + outline: none; + } + .filter-btn { + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + border-radius: 999px; + padding: 10px 14px; + font-size: 0.95rem; + cursor: pointer; + } + .filter-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent); + } + .meta { + color: var(--muted); + font-size: .95rem; + margin-bottom: 10px; + } + .table-wrap { + overflow: auto; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--surface); + } + table { + width: 100%; + border-collapse: collapse; + min-width: 1100px; + } + th, td { + padding: 14px 14px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: top; + } + th { + background: #f3faf5; + font-size: .85rem; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--muted); + position: sticky; + top: 0; + z-index: 1; + } + tr:hover td { + background: #f7fcf8; + } + .status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-weight: 600; + font-size: .85rem; + } + .status-pass { background: var(--good-bg); color: var(--good); } + .status-fail { background: var(--bad-bg); color: var(--bad); } + .status-other { background: var(--other-bg); color: var(--other); } + .mobile-list { + display: none; + gap: 14px; + } + .result-card { + border: 1px solid var(--line); + border-radius: 18px; + padding: 16px; + background: var(--surface); + box-shadow: var(--shadow); + } + .result-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; + margin-top: 12px; + } + .field-label { + font-size: .78rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .05em; + margin-bottom: 3px; + } + .field-value { + font-size: .96rem; + word-break: break-word; + } + @media (max-width: 860px) { + .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .table-wrap { display: none; } + .mobile-list { display: grid; } + } + + +/* extracted from GDT_TrackHistory.html */ + + :root { + --bg: #f2f8f3; + --surface: #ffffff; + --surface-2: #edf8f0; + --text: #163222; + --muted: #4e6a5a; + --line: #cfe4d5; + --accent: #1f7a3d; + --accent-2: #39a35c; + --success-bg: #e5f7eb; + --success-text: #135f31; + --shadow: 0 12px 30px rgba(24, 74, 44, 0.10); + --radius: 20px; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: + radial-gradient(circle at top left, rgba(57, 163, 92, 0.15), transparent 30%), + radial-gradient(circle at top right, rgba(31, 122, 61, 0.12), transparent 28%), + var(--bg); + color: var(--text); + } + + .subpage { + width: min(1180px, calc(100% - 32px)); + margin: 32px auto; + padding: 0 0 28px; + } + + .hero { + background: linear-gradient(135deg, #ffffff 0%, #edf8f0 100%); + border: 1px solid var(--line); + border-radius: 28px; + box-shadow: var(--shadow); + padding: 28px; + display: grid; + gap: 18px; + } + + .eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + padding: 8px 14px; + background: rgba(31, 122, 61, 0.10); + color: var(--accent); + border-radius: 999px; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.02em; + } + + .hero h1 { + margin: 0; + font-size: clamp(1.8rem, 3vw, 3rem); + line-height: 1.1; + } + + .hero p { + margin: 0; + max-width: 70ch; + color: var(--muted); + font-size: 1rem; + line-height: 1.6; + } + + .meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + } + + .meta-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + } + + .meta-label { + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 8px; + } + + .meta-value { + font-size: 1rem; + font-weight: 700; + word-break: break-word; + } + + .meta-value a { + color: var(--accent); + text-decoration: none; + } + + .toolbar { + margin-top: 22px; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .search-wrap { + flex: 1 1 280px; + display: flex; + gap: 10px; + } + + .search-wrap input { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--surface); + font: inherit; + color: var(--text); + outline: none; + } + + .search-wrap input:focus { + border-color: var(--accent-2); + box-shadow: 0 0 0 4px rgba(57, 163, 92, 0.15); + } + + .pill-group { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .pill { + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + border-radius: 999px; + padding: 11px 14px; + font: inherit; + cursor: pointer; + transition: 0.2s ease; + } + + .pill.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } + + .section { + margin-top: 24px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 28px; + box-shadow: var(--shadow); + overflow: hidden; + } + + .section-header { + padding: 22px 24px 10px; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .section-header h2 { + margin: 0; + font-size: 1.25rem; + } + + .section-header .count { + color: var(--muted); + font-size: 0.95rem; + } + + .table-wrap { + width: 100%; + overflow-x: auto; + padding: 0 14px 14px; + } + + table { + width: 100%; + border-collapse: collapse; + min-width: 720px; + } + + th, td { + text-align: left; + padding: 16px 14px; + border-bottom: 1px solid #edf1f7; + vertical-align: top; + } + + th { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + } + + td { + font-size: 0.98rem; + } + + .status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; + white-space: nowrap; + } + + .status.reporting { + background: var(--success-bg); + color: var(--success-text); + } + + .status.standard { + background: #eef2ff; + color: #4254c8; + } + + .device-link { + color: var(--accent); + text-decoration: none; + font-weight: 600; + } + + .cards { + display: none; + padding: 0 18px 18px; + gap: 14px; + } + + .device-card { + border: 1px solid var(--line); + border-radius: 20px; + padding: 18px; + background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%); + } + + .device-card h3 { + margin: 0 0 12px; + font-size: 1.05rem; + } + + .device-card .row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 10px; + padding: 6px 0; + font-size: 0.95rem; + } + + .device-card .label { + color: var(--muted); + } + + .empty-state { + padding: 28px 22px 34px; + text-align: center; + color: var(--muted); + display: none; + } + + .footer-note { + margin-top: 18px; + color: var(--muted); + font-size: 0.92rem; + text-align: center; + } + + @media (max-width: 760px) { + .subpage { + width: min(100% - 20px, 1180px); + margin: 16px auto; + } + + .hero, .section { + border-radius: 22px; + } + + .table-wrap { + display: none; + } + + .cards { + display: grid; + } + + .device-card .row { + grid-template-columns: 92px 1fr; + } + } + + +/* Shared cleaned layout */ +:root { + --danger: #b42318; + --danger-bg: #fdecec; + --ok: #146620; + --ok-bg: #e5f6ea; +} +.hidden { display: none !important; } +.text-input, input, select, textarea { + width: 100%; + max-width: 100%; + border: 1px solid var(--line); border-radius: 10px; - font-family: monospace; + padding: 10px 12px; + font: inherit; + color: var(--text); + background: #fff; } - -/* RESPONSIVE */ -@media (max-width: 720px) { - .container { - padding: 14px; - } -} \ No newline at end of file +textarea { resize: vertical; } +label { display: block; color: var(--muted); font-weight: 700; font-size: .9rem; margin-bottom: 8px; } +.button-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; } +.status-row { margin-top: 10px; color: var(--muted); } +.note { min-height: 1.3em; color: var(--muted); } +.action-btn.secondary { background: #f1f7f3; color: var(--accent); border: 1px solid #d6e5da; } +.action-btn.danger { background: var(--danger); color: white; } +.action-btn:disabled { opacity: .5; cursor: not-allowed; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; align-items: end; } +.status-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 16px; } +.chip { border: 1px solid #d7e7dc; border-radius: 10px; background: #f8fbf9; padding: 9px 11px; font-size: .9rem; color: #315541; } +.chip strong { color: #113521; } +.console-grid { display: grid; gap: 16px; grid-template-columns: 1fr; } +@media (min-width: 980px) { .console-grid { grid-template-columns: 1fr 1fr; } .console-grid .card-wide { grid-column: 1 / -1; } } +.topic-select { min-height: 140px; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } +.monitor-box { background: #081217; color: #b7f9cc; border: 1px solid rgba(0, 183, 255, .25); border-radius: 10px; padding: 14px; min-height: 280px; overflow: auto; white-space: pre-wrap; line-height: 1.35; } +.status-chip { display: inline-block; background: rgba(0, 183, 255, .12); border: 1px solid rgba(0, 183, 255, .35); border-radius: 20px; padding: 5px 10px; margin: 4px 6px 0 0; font-size: 13px; } +.page-note { font-size: .9rem; color: var(--muted); margin-top: 8px; } diff --git a/Firmware/GPAD_API/data/style.css.gz b/Firmware/GPAD_API/data/style.css.gz deleted file mode 100644 index 7a9e01a6..00000000 Binary files a/Firmware/GPAD_API/data/style.css.gz and /dev/null differ diff --git a/Firmware/GPAD_API/platformio.ini b/Firmware/GPAD_API/platformio.ini index 069f2257..701b4848 100644 --- a/Firmware/GPAD_API/platformio.ini +++ b/Firmware/GPAD_API/platformio.ini @@ -37,7 +37,6 @@ lib_deps = mathertel/RotaryEncoder@^1.5.3 knolleary/PubSubClient@^2.8 neu-rah/ArduinoMenu library@^4.21.5 - cygig/DailyStruggleButton@^0.5.1 neu-rah/streamFlow@0.0.0-alpha+sha.bf16ce8926 dfrobot/DFRobotDFPlayerMini@^1.0.6 tzapu/WiFiManager@^2.0.17 diff --git a/Firmware/GPAD_API/pre_extra_script.py b/Firmware/GPAD_API/pre_extra_script.py index 7b4cbd65..74fe0eda 100644 --- a/Firmware/GPAD_API/pre_extra_script.py +++ b/Firmware/GPAD_API/pre_extra_script.py @@ -1,9 +1,17 @@ Import("env") +from pathlib import Path +import re + +version_path = Path(env["PROJECT_DIR"]) / "FIRMWARE_VERSION" +firmware_version = version_path.read_text(encoding="utf-8").strip() +if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", firmware_version): + raise ValueError(f"Invalid semantic version in {version_path}: {firmware_version!r}") + cpp_defines = [ ("COMPANY_NAME", "PubInv "), # For the Broker ID for MQTT ("PROG_NAME", "GPAD_API "), # This program - ("FIRMWARE_VERSION", "0.58 "), # Refactored so that MQTT is serviced first. + ("FIRMWARE_VERSION", firmware_version), ("LittleFS_VERSION", "0.1.7 "), # pr 501 ("MODEL_NAME", "KRAKE_"), ("LICENSE", "GNU Affero General Public License, version 3 "), diff --git a/Firmware/factoryTest/component-Focused/MQTT/Krake_MQTT/Krake_MQTT.ino b/Firmware/factoryTest/component-Focused/MQTT/Krake_MQTT/Krake_MQTT.ino index e9f775a6..0cf889e3 100644 --- a/Firmware/factoryTest/component-Focused/MQTT/Krake_MQTT/Krake_MQTT.ino +++ b/Firmware/factoryTest/component-Focused/MQTT/Krake_MQTT/Krake_MQTT.ino @@ -36,9 +36,9 @@ const char* password = "textinsert"; // MQTT Broker -const char* mqtt_broker_name = "public.cloud.shiftr.io"; -const char* mqtt_user = "public"; -const char* mqtt_password = "public"; +const char* mqtt_broker_name = "krakepubinv.cloud.shiftr.io"; +const char* mqtt_user = "krakepubinv"; +const char* mqtt_password = "DlDmkWjp4I4kgDcA"; // MQTT Topics // User must modify the device serial number. In this case change the part "USA4" as approprate. diff --git a/Firmware/factoryTest/component-Focused/MQTT/mqtt_timing/mqtt_timing.ino b/Firmware/factoryTest/component-Focused/MQTT/mqtt_timing/mqtt_timing.ino index 0b3535b8..6a6c4951 100644 --- a/Firmware/factoryTest/component-Focused/MQTT/mqtt_timing/mqtt_timing.ino +++ b/Firmware/factoryTest/component-Focused/MQTT/mqtt_timing/mqtt_timing.ino @@ -53,9 +53,7 @@ void connect() { Serial.print("\nconnecting..."); #define ClientName "mqtt_timing" -// while (!client.connect("arduino", "public", "public")) { -// while (!client.connect("mqtt_timing", "public", "public")) { - while (!client.connect(ClientName, "public", "public")) { + while (!client.connect(ClientName, "krakepubinv", "DlDmkWjp4I4kgDcA")) { Serial.print("."); delay(1000); } @@ -111,7 +109,7 @@ void setup() { WiFi.begin(ssid, password); // Note: Local domain names (e.g. "Computer.local" on OSX) are not supported // by Arduino. You need to set the IP address directly. - client.begin("public.cloud.shiftr.io", net); + client.begin("krakepubinv.cloud.shiftr.io", net); client.onMessage(messageReceived); connect(); diff --git a/README.md b/README.md index 5955ba52..f1c73f8e 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The local settings menu includes: The Krake normally operates as a Wi-Fi station connected to a local network. -For setup and provisioning, the Krake can create a temporary Wi-Fi access point using WiFiManager and LittleFS credential storage. +For setup and provisioning, the Krake can create a temporary Wi-Fi access point using WiFiManager. Wi-Fi credentials are stored in ESP32 NVS so they survive LittleFS UI uploads. ## Features @@ -196,7 +196,8 @@ For setup and provisioning, the Krake can create a temporary Wi-Fi access point * MQTT connectivity monitoring * LCD network status display -Credentials are stored locally using LittleFS. +Wi-Fi credentials are stored locally in ESP32 NVS. A `/wifi.json` LittleFS mirror is maintained for backward compatibility and diagnostics, but NVS is authoritative so uploading a new LittleFS image does not erase the reconnect list. +At startup, saved-network retries and the fallback WiFiManager recovery portal are time-bounded so a missing or invalid configuration cannot indefinitely block the Krake hardware loop. --- diff --git a/pages/PMD_GPAD_API.html b/pages/PMD_GPAD_API.html index 01caff3a..25daefd2 100644 --- a/pages/PMD_GPAD_API.html +++ b/pages/PMD_GPAD_API.html @@ -149,24 +149,24 @@

PMD_GPAD_API

The PMD publishes to a "topic" which is made up of the model name, KRAKE, and serial number of the KRAKE. It is contatinated by an underscored.

Set MQTT Broker

- The default example MQTT broker is: mqtt://public:public@public.cloud.shiftr.io + The MQTT broker is fixed to: mqtt://krakepubinv@krakepubinv.cloud.shiftr.io
- Set up another broker with this form. + Use the Krake PubInv broker credentials below.

Setup MQTT Broker

- +

- +
- +
@@ -229,10 +229,10 @@
Public Invention, LICENSE "GNU Affero General Public License, version 3 "