diff --git a/.github/workflows/gpad-firmware-version.yml b/.github/workflows/gpad-firmware-version.yml new file mode 100644 index 00000000..e0fd113a --- /dev/null +++ b/.github/workflows/gpad-firmware-version.yml @@ -0,0 +1,43 @@ +name: Assign GPAD firmware PR version + +on: + pull_request_target: + branches: ["main"] + types: [opened, reopened] + +permissions: + contents: write + +jobs: + assign-version: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout pull request branch + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + 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 pull request + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + git show "$BASE_SHA:scripts/bump_gpad_firmware_version.py" > /tmp/bump_gpad_firmware_version.py + python3 /tmp/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 "Assign GPAD firmware version for PR #$PR_NUMBER" + git push origin HEAD:"$HEAD_REF" diff --git a/Firmware/GPAD_API/FIRMWARE_VERSION b/Firmware/GPAD_API/FIRMWARE_VERSION new file mode 100644 index 00000000..0627c1ee --- /dev/null +++ b/Firmware/GPAD_API/FIRMWARE_VERSION @@ -0,0 +1 @@ +0.58.4+pr.86 diff --git a/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp b/Firmware/GPAD_API/GPAD_API/DFPlayer.cpp index 5e27f2e1..d313aac2 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); } } @@ -318,20 +328,20 @@ void playNotBusy() } } -void playNotBusyLevel(int level) +bool playNotBusyLevel(int level) { - if (!isDFPlayerDetected) return; + if (!isDFPlayerDetected) return false; if (currentlyMuted) { DBG_PRINTLN(F("Muted: skipping DFPlayer playback.")); - return; + return false; } - if (level <= 0) + if (level <= 0) { DBG_PRINTLN(F("Silent level: skipping DFPlayer playback.")); - return; + return false; } DBG_PRINTLN(F("playNotBusyLevel")); @@ -343,6 +353,7 @@ void playNotBusyLevel(int level) else { DBG_PRINTLN(F("DFPlayer is still busy/playing.")); + return false; } if (dfPlayer.available()) @@ -350,8 +361,9 @@ void playNotBusyLevel(int level) printDetail(dfPlayer.readType(), dfPlayer.read()); } - if (!isDFPlayerDetected) return; + if (!isDFPlayerDetected) return false; + return true; } bool playAlarmLevel(int alarmNumberToPlay) @@ -367,7 +379,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..46fca715 100644 --- a/Firmware/GPAD_API/GPAD_API/DFPlayer.h +++ b/Firmware/GPAD_API/GPAD_API/DFPlayer.h @@ -12,7 +12,7 @@ void displayDFPlayerStats(); bool playAlarmLevel(int alarmNumberToPlay); void playNotBusy(); -void playNotBusyLevel(int level); +bool playNotBusyLevel(int level); void dfPlayerUpdate(void); void printDetail(uint8_t type, int value); @@ -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..047602b4 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,8 @@ #include "DFPlayer.h" #include "GPAD_menu.h" #include "mqtt_handler.h" +#include "operator_settings.h" +#include "spi_broker_mirror.h" #include "debug_macros.h" AsyncWebServer server(80); @@ -184,41 +188,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,10 +233,13 @@ 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; bool mqttReconnectRequested = false; +bool wifiConnectWinkIssued = false; enum BrokerState : uint8_t { BROKER_WAITING_WIFI, @@ -274,9 +274,19 @@ 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(); +void requestWifiCredentialsReset(); +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 +333,10 @@ 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; + wifiConnectWinkIssued = false; } @@ -351,7 +359,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 +376,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 +400,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 selectMqttBrokerOption(uint8_t index) +bool customMqttBrokerConfigured() { - if (index >= BROKER_OPTION_COUNT) + 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; +} + +void loadMqttBrokerPreferences() +{ + 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 +532,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 +548,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 +562,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); @@ -531,6 +586,7 @@ bool reconnect(bool force = false) { client.subscribe(watchedTopics[i]); } + queueWinkPattern(3); // Triple wink: MQTT connected. return true; } @@ -540,17 +596,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 +941,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 +957,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 +973,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 +1179,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 +1194,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 +1205,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 +1233,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 +1279,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 +1310,34 @@ bool applyMuteSetting(const String &rawValue) { return false; } - setMuted(requestedMutedState); + if (requestedMutedState) + { + setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); + } + else + { + unmute(); + } 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 +1377,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 +1385,6 @@ void turnOnAllLamps() } void turnOffAllLamps() { -#if defined(HMWK) - digitalWrite(LED_D9, LOW); -#endif digitalWrite(LIGHT0, LOW); digitalWrite(LIGHT1, LOW); digitalWrite(LIGHT2, LOW); @@ -1399,7 +1392,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 +1465,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 +1588,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 +1653,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 +1676,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 +1690,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 +1730,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 +1819,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 +1863,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 +1880,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 +1897,9 @@ 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 += "\"alarmRepeatSeconds\":" + String(alarmRepeatSeconds) + ","; payload += "\"muted\":" + String(isMuted() ? "true" : "false"); payload += "}"; sendTextResponse(request, 200, "application/json", payload); }); @@ -1812,6 +1907,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 +1939,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 +2011,64 @@ 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->hasParam("alarmRepeatSeconds", true)) + { + request->send(400, "text/plain", "missing sound setting"); + return; + } + String volumeText = request->getParam("volume", true)->value(); + String muteMinutesText = request->getParam("muteTimeoutMinutes", true)->value(); + String repeatSecondsText = request->getParam("alarmRepeatSeconds", true)->value(); + volumeText.trim(); + muteMinutesText.trim(); + repeatSecondsText.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; + } + } + for (size_t i = 0; i < repeatSecondsText.length(); i++) + { + if (!isDigit(repeatSecondsText[i])) + { + request->send(400, "text/plain", "invalid sound setting"); + return; + } + } + const int requestedVolume = volumeText.toInt(); + const int requestedMuteMinutes = muteMinutesText.toInt(); + const int requestedRepeatSeconds = repeatSecondsText.toInt(); + if (volumeText.length() == 0 || muteMinutesText.length() == 0 || repeatSecondsText.length() == 0 || + requestedVolume < OPERATOR_VOLUME_MIN_PERCENT || requestedVolume > OPERATOR_VOLUME_MAX_PERCENT || + requestedMuteMinutes < OPERATOR_MUTE_TIMEOUT_MIN_MINUTES || requestedMuteMinutes > OPERATOR_MUTE_TIMEOUT_MAX_MINUTES || + requestedRepeatSeconds < OPERATOR_ALARM_REPEAT_MIN_SECONDS || requestedRepeatSeconds > OPERATOR_ALARM_REPEAT_MAX_SECONDS) + { + request->send(400, "text/plain", "invalid sound setting"); + return; + } + setVolume(requestedVolume); + muteTimeoutMinutes = requestedMuteMinutes; + alarmRepeatSeconds = requestedRepeatSeconds; + if (!saveVolumeSetting(volumeDFPlayer) || !saveMuteTimeoutMinutesSetting(muteTimeoutMinutes) || !saveAlarmRepeatSecondsSetting(alarmRepeatSeconds)) + { + 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 +2097,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) { @@ -1972,7 +2113,7 @@ void setupOTA() server.on("/settings/wifi/reset", HTTP_POST, [](AsyncWebServerRequest *request) { - wifiResetRequestedAtMs = millis(); + requestWifiCredentialsReset(); request->send(200, "text/plain", "wifi reset scheduled"); }); server.on("/broker-console", HTTP_GET, [](AsyncWebServerRequest *request) @@ -2045,6 +2186,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 +2200,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 +2221,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 +2229,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 +2247,12 @@ void setupOTA() void handleWifiConnected() { -#if defined HMWK || defined KRAKE +#if defined(KRAKE) + if (!wifiConnectWinkIssued) + { + queueWinkPattern(2); // Double wink: WiFi connected. + wifiConnectWinkIssued = true; + } if (!client.connected()) { reconnect(true); @@ -2126,11 +2280,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 +2309,14 @@ 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); + alarmRepeatSeconds = loadAlarmRepeatSecondsSetting(alarmRepeatSeconds); WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); wifiManager.initialize(); @@ -2174,34 +2330,19 @@ 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 defined(KRAKE) + initializeSpiBrokerMirror(); +#endif #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 +2364,7 @@ void setup() publish_Default_Topic[MAX_TOPIC_LEN - 1] = '\0'; loadMqttConfig(); - applyActiveMqttBrokerConfig(); + applyMqttBrokerConfig(); #if (DEBUG > 1) debugSerial.println("XXXXXXX"); @@ -2241,7 +2382,7 @@ void setup() // clearLCD(); // req for Wifi Man and OTA -#if defined HMWK || defined KRAKE +#if defined(KRAKE) wifiManager.setConnectedCallback(handleWifiConnected); #endif @@ -2272,7 +2413,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 +2448,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 +2503,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; } @@ -2359,16 +2527,22 @@ void serviceRuntimeDiagnostics() #endif } +void requestWifiCredentialsReset() +{ +#if defined(KRAKE) + wifiResetRequestedAtMs = millis(); +#endif +} + 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 +2557,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 +2572,10 @@ void loop() { // MQTT gets the first and most frequent slices so inbound bursts are drained // before comparatively slow LCD/menu/audio work is serviced. +#if defined(KRAKE) + serviceSpiBrokerMirror(); +#endif + serviceWiFiReconnect(); serviceMqttClient(); serviceDeferredReset(); @@ -2407,11 +2585,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,8 +2614,9 @@ void loop() serviceMqttClient(); } -#if defined HMWK || defined KRAKE +#if defined(KRAKE) publishOnLineMsg(); + serviceSpiBrokerMirror(); serviceHeapDiagnostics(); serviceRuntimeDiagnostics(); wink(); // The builtin LED diff --git a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp index 941800f8..63ee89dc 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp +++ b/Firmware/GPAD_API/GPAD_API/GPAD_HAL.cpp @@ -25,8 +25,9 @@ #include #include "WiFiManagerOTA.h" #include "GPAD_menu.h" -#include "mqtt_handler.h" #include "debug_macros.h" +#include "setup_status.h" +#include "operator_settings.h" #include #include #include @@ -155,16 +156,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; @@ -175,16 +210,17 @@ extern bool running_menu; extern char macAddressString[13]; extern int muteTimeoutMinutes; +extern int alarmRepeatSeconds; 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 @@ -257,6 +293,10 @@ namespace unsigned long lastAlarmUiRequestMs = 0; unsigned long lastAlarmUiUpdateMs = 0; unsigned long lastAlarmAudioUpdateMs = 0; + unsigned long lastAlarmAudioAttemptMs = 0; + unsigned long lastAlarmAudioPlaybackMs = 0; + bool alarmAudioPlaybackStarted = false; + const unsigned long ALARM_AUDIO_RETRY_INTERVAL_MS = 1000; uint8_t alarmUiPendingRequestCount = 0; bool lcdDirty = true; bool alarmActionSelectorActive = false; @@ -267,7 +307,7 @@ namespace char wrappedLines[MAX_WRAPPED_LINES][LCD_COLS + 1]; uint8_t wrappedLineCount = 0; uint8_t alarmPage = 0; - bool iconFocusActive = false; + bool iconFocusActive = true; enum LcdFocus : uint8_t { FOCUS_ALARM_ACTIONS = 0, @@ -294,13 +334,15 @@ namespace ACTION_FEEDBACK = 4, INFO_PAGE = 5, }; - LcdFocus lcdFocus = FOCUS_SETTINGS; + LcdFocus lcdFocus = FOCUS_WIFI; LcdPage lcdPage = PAGE_MAIN; LcdUiState lcdUiState = MAIN_PAGE; 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 +380,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 +759,7 @@ namespace } const unsigned long now = millis(); - if ((now - muteTimeoutEndMillis) < 0x80000000UL) + if (millisDeadlineReached(now, muteTimeoutEndMillis)) { return 0; } @@ -748,7 +799,7 @@ namespace const char *mqttStatusText() { - if (client.connected()) + if (mqttClientConnected()) { return "Connected"; } @@ -783,18 +834,18 @@ namespace dest[out] = '\0'; } - void setLcdCursorMode(bool enabled, uint8_t col = 19, uint8_t row = 0) + void updateMainPageCursor() { - if (enabled) + // Nested screens render stable text markers. Only the main-page WiFi, + // broker, mute, and settings icons use the LCD hardware cursor, and the + // cursor remains steady instead of blinking over the selected icon. + lcd.noBlink(); + lcd.noCursor(); + if (!running_menu && lcdUiState == MAIN_PAGE && iconFocusActive && + lcdFocus >= FOCUS_WIFI && lcdFocus <= FOCUS_SETTINGS) { - lcd.setCursor(col, row); + lcd.setCursor(LCD_STATUS_COL + static_cast(lcdFocus) - FOCUS_WIFI, 0); lcd.cursor(); - lcd.blink(); - } - else - { - lcd.noBlink(); - lcd.noCursor(); } } @@ -810,7 +861,7 @@ namespace uint8_t brokerStatusIcon() { - if (client.connected()) + if (mqttClientConnected()) { return 'B'; } @@ -818,7 +869,7 @@ namespace { return '_'; } - return (mqttFailCount > 0 || activeBrokerIndex != selectedBrokerIndex) ? '?' : '_'; + return mqttFailCount > 0 ? '?' : '_'; } uint8_t volumeStatusIcon() @@ -910,7 +961,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; } @@ -933,44 +984,7 @@ namespace lastLcdRenderMs = now; } - setLcdCursorMode(false); - if (!running_menu) - { - if (lcdUiState == ALARM_ACTION_SELECT) - { - const uint8_t actionCols[ALARM_ACTION_COUNT] = {0, 5, 9, 13}; - setLcdCursorMode(true, actionCols[alarmActionSelection], 3); - } - else if (lcdUiState == MAIN_PAGE) - { - if (iconFocusActive && lcdFocus >= FOCUS_WIFI && lcdFocus <= FOCUS_SETTINGS) - { - setLcdCursorMode(true, LCD_STATUS_COL + static_cast(lcdFocus) - FOCUS_WIFI, 0); - } - } - else if (lcdUiState == ICON_MENU) - { - uint8_t optionCol = 0; - uint8_t optionRow = 2; - if (lcdPage == PAGE_WIFI) - { - optionRow = 3; - } - else if (lcdPage == PAGE_BROKER) - { - optionRow = (lcdPageOption == 0) ? 1 : (lcdPageOption == 1 ? 2 : 3); - } - else if (lcdPage == PAGE_MUTE) - { - optionRow = (lcdPageOption == 0) ? 2 : 3; - if (lcdPageOption == 2) - { - optionCol = currentlyMuted ? 9 : 11; - } - } - setLcdCursorMode(true, optionCol, optionRow); - } - } + updateMainPageCursor(); lcdDirty = false; } @@ -990,9 +1004,36 @@ 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 || millisIntervalElapsed(now, lastRun, interval); + } +} + +void cancelPendingAlarmAudio() +{ + alarmAudioUpdatePending = false; + pendingAlarmAudioLevel = silent; + lastAlarmAudioAttemptMs = 0; + lastAlarmAudioPlaybackMs = 0; + alarmAudioPlaybackStarted = false; +} + +void serviceAlarmAudioRepeat() +{ + if (currentLevel <= silent || currentlyMuted) + { + return; + } + + const unsigned long now = millis(); + const unsigned long repeatIntervalMs = static_cast(alarmRepeatSeconds) * 1000UL; + const bool repeatDue = alarmAudioPlaybackStarted && millisIntervalElapsed(now, lastAlarmAudioPlaybackMs, repeatIntervalMs); + const bool retryDue = !alarmAudioPlaybackStarted && isDue(now, lastAlarmAudioAttemptMs, ALARM_AUDIO_RETRY_INTERVAL_MS); + if (!alarmAudioUpdatePending && (repeatDue || retryDue)) { - return lastRun == 0 || (now - lastRun) >= interval; + alarmAudioUpdatePending = true; + pendingAlarmAudioLevel = currentLevel; } } @@ -1015,14 +1056,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 +1090,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 +1152,33 @@ 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; + unmute(); } + 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 +1228,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 +1249,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 +1260,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 +1291,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) { - publishAck(mqttClient, line); + serialport->println(safeLine); + } + if (lineHandler != nullptr) + { + lineHandler(safeLine); } } @@ -1379,70 +1317,209 @@ void formatUptime(char *dest, size_t destLen) snprintf(dest, destLen, "%02lu:%02lu:%02lu", hours, minutes, seconds); } -void printSystemInfo(Stream *serialport, PubSubClient *mqttClient) -{ - char value[80]; - printAndPublishStatusLine(serialport, mqttClient, "=== 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); - snprintf(line, sizeof(line), "FW: %s", FIRMWARE_VERSION); - printAndPublishStatusLine(serialport, mqttClient, line); - ipAddressText(value, sizeof(value)); - snprintf(line, sizeof(line), "IP: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); - snprintf(line, sizeof(line), "MAC: %s", macAddressString); - printAndPublishStatusLine(serialport, mqttClient, 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); - snprintf(line, sizeof(line), "Heap: %lu", static_cast(ESP.getFreeHeap())); - printAndPublishStatusLine(serialport, mqttClient, line); - formatUptime(value, sizeof(value)); - snprintf(line, sizeof(line), "Uptime: %s", value); - printAndPublishStatusLine(serialport, mqttClient, line); - printAndPublishStatusLine(serialport, mqttClient, "========================="); +struct SystemInfoSnapshot +{ + char serialNumber[16]; + char firmwareVersion[32]; + char ipAddress[24]; + char macAddress[20]; + char ssid[80]; + char brokerProfile[32]; + char mqtt[128]; + char connectedBroker[80]; + unsigned long heapBytes; + char uptime[24]; + char resetReason[48]; + char setupErrors[128]; +}; + +void copySystemInfoValue(char *dest, size_t destLen, const char *src) +{ + if (destLen == 0) + { + return; + } + snprintf(dest, destLen, "%s", src != nullptr ? src : ""); +} + +SystemInfoSnapshot captureSystemInfo() +{ + SystemInfoSnapshot info; + snprintf(info.serialNumber, sizeof(info.serialNumber), "KRAKE-%.6s", macAddressString + 6); + copySystemInfoValue(info.firmwareVersion, sizeof(info.firmwareVersion), FIRMWARE_VERSION); + ipAddressText(info.ipAddress, sizeof(info.ipAddress)); + copySystemInfoValue(info.macAddress, sizeof(info.macAddress), macAddressString); + currentSsid(info.ssid, sizeof(info.ssid)); + copySystemInfoValue(info.brokerProfile, sizeof(info.brokerProfile), activeMqttBrokerLabel()); + snprintf(info.mqtt, sizeof(info.mqtt), "%s (%s)", brokerConnectionStateText(), connectedMqttBroker()); + copySystemInfoValue(info.connectedBroker, sizeof(info.connectedBroker), connectedMqttBroker()); + info.heapBytes = static_cast(ESP.getFreeHeap()); + formatUptime(info.uptime, sizeof(info.uptime)); + copySystemInfoValue(info.resetReason, sizeof(info.resetReason), resetReasonToString(esp_reset_reason())); + formatSetupErrors(info.setupErrors, sizeof(info.setupErrors)); + return info; +} + +void printJsonString(Print *output, const char *value) +{ + output->print('"'); + const char *safeValue = value != nullptr ? value : ""; + for (size_t i = 0; safeValue[i] != '\0'; i++) + { + const uint8_t ch = static_cast(safeValue[i]); + switch (ch) + { + case '"': + output->print(F("\\\"")); + break; + case '\\': + output->print(F("\\\\")); + break; + case '\b': + output->print(F("\\b")); + break; + case '\f': + output->print(F("\\f")); + break; + case '\n': + output->print(F("\\n")); + break; + case '\r': + output->print(F("\\r")); + break; + case '\t': + output->print(F("\\t")); + break; + default: + if (ch < 0x20) + { + char escaped[7]; + snprintf(escaped, sizeof(escaped), "\\u%04x", ch); + output->print(escaped); + } + else + { + output->write(ch); + } + break; + } + } + output->print('"'); +} + +void printJsonField(Stream *serialport, const __FlashStringHelper *key, const char *value, bool trailingComma = true) +{ + serialport->print('"'); + serialport->print(key); + serialport->print(F("\":")); + printJsonString(serialport, value); + if (trailingComma) + { + serialport->print(','); + } +} + +void printSystemInfo(Stream *serialport, SystemInfoLineHandler lineHandler) +{ + const SystemInfoSnapshot info = captureSystemInfo(); + char line[160]; + reportStatusLine(serialport, lineHandler, "=== KRAKE SYSTEM INFO ==="); + snprintf(line, sizeof(line), "SN: %s", info.serialNumber); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "FW: %s", info.firmwareVersion); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "IP: %s", info.ipAddress); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "MAC: %s", info.macAddress); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "SSID: %s", info.ssid); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Broker profile: %s", info.brokerProfile); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "MQTT: %s", info.mqtt); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Connected broker: %s", info.connectedBroker); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Heap: %lu", info.heapBytes); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Uptime: %s", info.uptime); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Reset reason: %s", info.resetReason); + reportStatusLine(serialport, lineHandler, line); + snprintf(line, sizeof(line), "Setup errors: %s", info.setupErrors); + reportStatusLine(serialport, lineHandler, line); + reportStatusLine(serialport, lineHandler, "========================="); +} + +void printSystemInfoJson(Stream *serialport) +{ + if (serialport == nullptr) + { + return; + } + + const SystemInfoSnapshot info = captureSystemInfo(); + serialport->print('{'); + printJsonField(serialport, F("sn"), info.serialNumber); + printJsonField(serialport, F("fw"), info.firmwareVersion); + printJsonField(serialport, F("ip"), info.ipAddress); + printJsonField(serialport, F("mac"), info.macAddress); + printJsonField(serialport, F("ssid"), info.ssid); + printJsonField(serialport, F("brokerProfile"), info.brokerProfile); + printJsonField(serialport, F("mqtt"), info.mqtt); + printJsonField(serialport, F("connectedBroker"), info.connectedBroker); + serialport->print(F("\"heap\":")); + serialport->print(info.heapBytes); + serialport->print(','); + printJsonField(serialport, F("uptime"), info.uptime); + printJsonField(serialport, F("resetReason"), info.resetReason); + printJsonField(serialport, F("setupErrors"), info.setupErrors, false); + serialport->println('}'); } 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; + result.responseIsJson = (command == 'j'); + if (!result.responseIsJson) + { + serialport->print(F("Command: ")); + serialport->printf("%c\n", command); } - serialport->print(F("Command: ")); - serialport->printf("%c\n", command); switch (command) { case 's': { serialport->println(F("Muting Case!")); - setMuted(true); + setMuteTimeoutMinutes(OPERATOR_MUTE_TIMEOUT_INFINITE_MINUTES); + result.includeAudioRefresh = true; break; } case 'u': { serialport->println(F("UnMuting Case!")); - setMuted(false); + unmute(); + result.includeAudioRefresh = true; break; } case 'h': @@ -1457,8 +1534,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 +1563,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,39 +1579,55 @@ 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 'j': + { + printSystemInfoJson(serialport); 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; } } - serialport->print(F("currentlyMuted : ")); - serialport->println(currentlyMuted); - serialport->println(F("interpret Done")); - // FLE delay(3000); + if (!result.responseIsJson) + { + serialport->print(F("currentlyMuted : ")); + serialport->println(currentlyMuted); + serialport->println(F("interpret Done")); + } + return result; } // end interpretBuffer() void muteTimeoutWatchdog(Stream *serialport) @@ -1578,7 +1673,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; } @@ -1597,10 +1692,12 @@ void serviceAlarmUiAudio(Stream *serialport) lastAlarmAudioUpdateMs = now; const AlarmLevel audioLevel = pendingAlarmAudioLevel; + lastAlarmAudioAttemptMs = now; if (audioLevel <= 0) { serialport->println(F("Silent level: skipping DFPlayer playback.")); + cancelPendingAlarmAudio(); } else if (currentlyMuted) { @@ -1610,6 +1707,11 @@ void serviceAlarmUiAudio(Stream *serialport) { serialport->println(F("dfPlayer.play")); serialport->println(audioLevel); + // Advance the repeat window for every playback attempt. If the DFPlayer + // is still busy, this prevents rapid retries and preserves a quiet delay + // before the scheduler checks again. + lastAlarmAudioPlaybackMs = now; + alarmAudioPlaybackStarted = true; playNotBusyLevel(audioLevel); } } @@ -1619,14 +1721,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); @@ -1637,6 +1741,7 @@ void GPAD_HAL_loop() alarmUiUpdatePending = true; lastAlarmUiRequestMs = now; } + serviceAlarmAudioRepeat(); serviceAlarmUiAudio(local_ptr_to_serial); } @@ -1684,20 +1789,26 @@ void noteLcdQueueMessageReceived() markLcdDirty(); } +void noteLcdUiInteraction() +{ + lastLcdUiInteractionMs = millis(); +} + void resetLcdUiToMainPage() { lcdPage = PAGE_MAIN; lcdPageOption = 0; alarmActionSelectorActive = false; - iconFocusActive = false; actionFeedbackText[0] = '\0'; if (alarmIsActive()) { + iconFocusActive = false; lcdFocus = FOCUS_ALARM_ACTIONS; alarmActionSelection = 0; } else { + iconFocusActive = true; lcdFocus = FOCUS_WIFI; } setLcdUiState(MAIN_PAGE); @@ -1777,6 +1888,11 @@ void executeSelectedAlarmAction() bool alarmActionSelectorHandleRotation(bool clockwise) { + noteLcdUiInteraction(); + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } if (lcdUiState == INFO_PAGE && lcdPage == PAGE_INFO) { if (clockwise) @@ -1887,6 +2003,11 @@ bool alarmActionSelectorHandleRotation(bool clockwise) bool alarmActionSelectorHandlePress() { + noteLcdUiInteraction(); + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } if (lcdUiState == INFO_PAGE) { returnToMainPage(); @@ -1913,12 +2034,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 { @@ -1931,14 +2052,13 @@ bool alarmActionSelectorHandlePress() { resetLcdUiToMainPage(); setLcdUiState(SETTINGS_MENU); - open_settings_menu_at(3); + open_settings_menu_at(4); // Main menu index for the mute-duration submenu. } else if (lcdPageOption == 1) { if (currentlyMuted) { - clearMuteTimeout(); - setMuted(false); + unmute(); } else { @@ -2005,6 +2125,10 @@ bool alarmActionSelectorHandlePress() { executeSelectedAlarmAction(); } + if (lcdUiState == ICON_MENU) + { + noteMenuInteraction(); + } markLcdDirty(); requestAlarmRefresh(local_ptr_to_serial, false); return true; @@ -2031,7 +2155,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(); @@ -2102,6 +2225,17 @@ void filter_control_chars(char *msg) msg[k] = '\0'; } +void renderAlarmActionChoices(char *row) +{ + static const char *choices[ALARM_ACTION_COUNT] = { + ">SHLV ACK DIS CMP", + " SHLV>ACK DIS CMP", + " SHLV ACK>DIS CMP", + " SHLV ACK DIS>CMP", + }; + formatFullRow(row, "%s", choices[alarmActionSelection]); +} + void renderWifiPage(char rows[LCD_ROWS][LCD_COLS + 1]) { char ssid[21]; @@ -2127,9 +2261,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 ? '>' : ' '); } @@ -2137,7 +2271,14 @@ void renderMutePage(char rows[LCD_ROWS][LCD_COLS + 1]) { const unsigned long muteMinutes = remainingMuteMinutes(); formatFullRow(rows[0], "Mute"); - formatFullRow(rows[1], "Mute set:%lu min", currentlyMuted ? muteMinutes : (unsigned long)muteTimeoutMinutes); + if ((currentlyMuted && muteTimeoutEndMillis == 0) || (!currentlyMuted && muteTimeoutMinutes == OPERATOR_MUTE_TIMEOUT_INFINITE_MINUTES)) + { + formatFullRow(rows[1], "Mute set:Infinite"); + } + else + { + formatFullRow(rows[1], "Mute set:%lu min", currentlyMuted ? muteMinutes : (unsigned long)muteTimeoutMinutes); + } formatFullRow(rows[2], "%cMute settings", lcdPageOption == 0 ? '>' : ' '); formatFullRow(rows[3], "%c%s %cBack", lcdPageOption == 1 ? '>' : ' ', @@ -2170,7 +2311,7 @@ void renderInfoPage(char rows[LCD_ROWS][LCD_COLS + 1]) formatFullRow(rows[1], "SSID:%.15s", ssid); formatFullRow(rows[2], "MQTT:%.15s", brokerConnectionStateText()); } - formatFullRow(rows[3], "Back"); + formatFullRow(rows[3], ">Back"); } void renderWifiStatusPage(char rows[LCD_ROWS][LCD_COLS + 1]) @@ -2185,14 +2326,14 @@ void renderWifiStatusPage(char rows[LCD_ROWS][LCD_COLS + 1]) formatFullRow(rows[0], "WiFi:%.15s", ssid); formatFullRow(rows[1], "IP:%s", ip); formatFullRow(rows[2], "Open Web UI"); - formatFullRow(rows[3], "Press: Back"); + formatFullRow(rows[3], ">Back (press)"); } else { formatFullRow(rows[0], "WiFi Setup"); formatFullRow(rows[1], "AP:Krake-Setup"); formatFullRow(rows[2], "Go:%s", ip); - formatFullRow(rows[3], "Press: Back"); + formatFullRow(rows[3], ">Back (press)"); } } @@ -2249,7 +2390,11 @@ void showStatusLCD(AlarmLevel level, bool muted, char *msg) } formatMain(rows[0], "PAQ:0"); formatMain(rows[1], "System OK"); - if (currentlyMuted) + if (currentlyMuted && muteTimeoutEndMillis == 0) + { + formatFullRow(rows[2], "Vol:%02d Mute:Inf", volumeDFPlayer); + } + else if (currentlyMuted) { formatFullRow(rows[2], "Vol:%02d Mute:%lum", volumeDFPlayer, remainingMuteMinutes()); } @@ -2258,9 +2403,13 @@ void showStatusLCD(AlarmLevel level, bool muted, char *msg) formatFullRow(rows[2], "Vol:%02d Mute:Off", volumeDFPlayer); } alarmActionSelectorActive = false; - if (lcdFocus == FOCUS_ALARM_ACTIONS) + if (lcdUiState == MAIN_PAGE) { - lcdFocus = FOCUS_SETTINGS; + if (lcdFocus == FOCUS_ALARM_ACTIONS) + { + lcdFocus = FOCUS_WIFI; + } + iconFocusActive = true; } } else @@ -2317,7 +2466,11 @@ void showStatusLCD(AlarmLevel level, bool muted, char *msg) if (lcdUiState == ALARM_ACTION_SELECT) { - formatFullRow(rows[3], "SHLV ACK DIS CMP"); + renderAlarmActionChoices(rows[3]); + } + else if (lcdUiState == MAIN_PAGE && !iconFocusActive) + { + formatFullRow(rows[3], "Select: Alarm menu"); } if (lcdUiState == ACTION_FEEDBACK) @@ -2330,7 +2483,6 @@ void showStatusLCD(AlarmLevel level, bool muted, char *msg) { formatFullRow(rows[3], "%s", actionFeedbackText); } - (void)muted; writeStatusIcons(rows); renderRows(rows); @@ -2363,7 +2515,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..54152868 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,23 @@ 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 responseIsJson = false; + bool publishResetAck = false; + bool restartRequested = false; +}; + +typedef void (*SystemInfoLineHandler)(const char *line); +void printSystemInfo(Stream *serialport, SystemInfoLineHandler lineHandler = nullptr); +void printSystemInfoJson(Stream *serialport); +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..14610235 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp +++ b/Firmware/GPAD_API/GPAD_API/GPAD_menu.cpp @@ -7,23 +7,38 @@ #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); +extern void requestWifiCredentialsReset(); #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 +60,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 +72,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 +86,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 +100,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 +115,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); } @@ -148,7 +154,23 @@ result actionResetConfirm(eventMask e) return proceed; } +result actionWifiCredentialsClearConfirm(eventMask e) +{ + if (e == eventMask::enterEvent) + { + DBG_PRINTLN(F("WiFi credential clear confirmed. Scheduling restart...")); + running_menu = false; + menu_just_exited = false; + Menu::doExit(); + resetLcdUiToMainPage(); + showActionFeedback("Clearing WiFi..."); + requestWifiCredentialsReset(); + } + return proceed; +} + int muteTimeoutMinutes = 5; +int alarmRepeatSeconds = OPERATOR_ALARM_REPEAT_DEFAULT_SECONDS; const uint32_t kBaudOptions[] = {1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200}; @@ -224,17 +246,55 @@ result actionMuteTimeout(eventMask e) DBG_PRINT(F("Mute timeout set: ")); DBG_PRINT(muteTimeoutMinutes); DBG_PRINTLN(F(" min")); + saveMuteTimeoutMinutesSetting(muteTimeoutMinutes); requestAlarmRefresh(&Serial); } return proceed; } +result actionAlarmRepeatSeconds(eventMask e) +{ + if (e == eventMask::enterEvent) + { + DBG_PRINT(F("Alarm repeat interval set: ")); + DBG_PRINT(alarmRepeatSeconds); + DBG_PRINTLN(F(" sec")); + saveAlarmRepeatSecondsSetting(alarmRepeatSeconds); + } + return proceed; +} + +void finishMuteMenuAction(const char *feedback) +{ + running_menu = false; + menu_just_exited = false; + Menu::doExit(); + resetLcdUiToMainPage(); + showActionFeedback(feedback); + requestAlarmRefresh(&Serial); +} + result actionMuteNow(eventMask e) { if (e == eventMask::enterEvent) { + saveMuteTimeoutMinutesSetting(muteTimeoutMinutes); setMuteTimeoutMinutes((unsigned long)muteTimeoutMinutes); - requestAlarmRefresh(&Serial); + DBG_PRINT(F("Muted for ")); + DBG_PRINT(muteTimeoutMinutes); + DBG_PRINTLN(F(" minutes.")); + finishMuteMenuAction("Muted: timed"); + } + return proceed; +} + +result actionMuteInfinite(eventMask e) +{ + if (e == eventMask::enterEvent) + { + setMuteTimeoutMinutes(OPERATOR_MUTE_TIMEOUT_INFINITE_MINUTES); + DBG_PRINTLN(F("Muted indefinitely until manual unmute or received u command.")); + finishMuteMenuAction("Muted: Infinite"); } return proceed; } @@ -243,9 +303,9 @@ result actionUnmuteNow(eventMask e) { if (e == eventMask::enterEvent) { - clearMuteTimeout(); - setMuted(false); - requestAlarmRefresh(&Serial); + unmute(); + DBG_PRINTLN(F("Unmuted.")); + finishMuteMenuAction("Unmuted"); } return proceed; } @@ -261,32 +321,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; } @@ -329,20 +387,26 @@ MENU(resetConfirmMenu, "Reset Device", Menu::doNothing, Menu::noEvent, Menu::noS OP("Back", actionBack, enterEvent) ); +MENU(wifiCredentialsClearMenu, "Clear WiFi Creds?", Menu::doNothing, Menu::noEvent, Menu::noStyle, + OP("Confirm Clear", actionWifiCredentialsClearConfirm, enterEvent), + OP("Back", actionBack, enterEvent) +); + MENU(wifiMenu, "WiFi", Menu::doNothing, Menu::noEvent, Menu::wrapStyle, OP("Status / Web UI", actionWifiStatus, enterEvent), OP("Back", actionBack, enterEvent) ); 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) ); MENU(muteMenu, "Mute Duration", Menu::doNothing, Menu::noEvent, Menu::wrapStyle, FIELD(muteTimeoutMinutes, "Set Duration", "min", 1, 60, 5, 1, actionMuteTimeout, enterEvent, wrapStyle), OP("Mute Now", actionMuteNow, enterEvent), + OP("Mute Infinite", actionMuteInfinite, enterEvent), OP("Unmute", actionUnmuteNow, enterEvent), OP("Back", actionBack, enterEvent) ); @@ -351,13 +415,15 @@ MENU(developerMenu, "Developer Mode", Menu::doNothing, Menu::noEvent, Menu::wrap SUBMENU(brokerMenu), SUBMENU(comSetupMenu), OP("Diagnostics", actionDiagnostics, enterEvent), + SUBMENU(wifiCredentialsClearMenu), OP("Back", actionBack, enterEvent) ); 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), + FIELD(alarmRepeatSeconds, "Alarm Repeat", "sec", 1, 300, 10, 1, actionAlarmRepeatSeconds, enterEvent, wrapStyle), SUBMENU(muteMenu), SUBMENU(developerMenu), SUBMENU(resetConfirmMenu), @@ -386,6 +452,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 +461,7 @@ void registerRotationEvent(bool CW) void registerRotaryEncoderPress() { + noteMenuInteraction(); reIn.registerEvent(RotaryEventIn::EventType::BUTTON_CLICKED); } @@ -407,6 +475,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 +520,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..eb4a2782 100644 --- a/Firmware/GPAD_API/GPAD_API/GPAD_menu.h +++ b/Firmware/GPAD_API/GPAD_API/GPAD_menu.h @@ -3,6 +3,9 @@ void setup_GPAD_menu(); +extern int muteTimeoutMinutes; +extern int alarmRepeatSeconds; + void poll_GPAD_menu(); void navigate_to_n_and_execute(int n); @@ -10,6 +13,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/RickmanLiquidCrystal_I2C.h b/Firmware/GPAD_API/GPAD_API/RickmanLiquidCrystal_I2C.h index a25563d2..cee8674a 100644 --- a/Firmware/GPAD_API/GPAD_API/RickmanLiquidCrystal_I2C.h +++ b/Firmware/GPAD_API/GPAD_API/RickmanLiquidCrystal_I2C.h @@ -41,14 +41,15 @@ namespace Menu // text editor cursor device->noBlink(); device->noCursor(); - if (editing) - { - device->setCursor(x, y); - if (charEdit) - device->cursor(); - else - device->blink(); - } + // Selection is rendered by ArduinoMenu's stable leading marker. Avoid + // the hardware blink cycle so the active choice remains continuously + // visible while values are edited. + (void)root; + (void)x; + (void)y; + (void)editing; + (void)charEdit; + (void)panelNr; return 0; } }; 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..09d2da98 100644 --- a/Firmware/GPAD_API/GPAD_API/Wink.cpp +++ b/Firmware/GPAD_API/GPAD_API/Wink.cpp @@ -3,34 +3,90 @@ // Date: 20241013 // LICENSE "GNU Affero General Public License, version 3 " -// Heart beat aka activity indicator LED. -// Set LED for Uno or ESP32 Dev Kit on board blue LED. +// Heart beat aka activity indicator LED, plus queued connection indicators. #include +#include "gpad_utility.h" + +namespace +{ + const uint8_t KRAKE_LED_BUILTIN = 13; + const unsigned long HEARTBEAT_ON_MS = 1400; + const unsigned long HEARTBEAT_OFF_MS = 500; + const unsigned long PATTERN_ON_MS = 120; + const unsigned long PATTERN_OFF_MS = 140; + volatile uint8_t pendingPatternPulses = 0; +} + +void queueWinkPattern(uint8_t pulseCount) +{ + if (pulseCount > pendingPatternPulses) + { + pendingPatternPulses = pulseCount; + } +} -// 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 - pinMode(LED_BUILTIN, OUTPUT); - // const int HIGH_TIME_LED = 900; - // const int LOW_TIME_LED = 100; - const int HIGH_TIME_LED = 1400; - 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)) + static bool initialized = false; + static bool ledOn = false; + static bool patternActive = false; + static uint8_t patternPulsesRemaining = 0; + static unsigned long lastLedChangeMs = 0; + static unsigned long nextLedChangeMs = HEARTBEAT_OFF_MS; + const unsigned long now = millis(); + + if (!initialized) + { + pinMode(KRAKE_LED_BUILTIN, OUTPUT); + digitalWrite(KRAKE_LED_BUILTIN, LOW); + initialized = true; + } + + if (!patternActive && pendingPatternPulses > 0) { - if (digitalRead(LED_BUILTIN) == LOW) + patternPulsesRemaining = pendingPatternPulses; + pendingPatternPulses = 0; + patternActive = true; + ledOn = true; + digitalWrite(KRAKE_LED_BUILTIN, HIGH); + lastLedChangeMs = now; + nextLedChangeMs = PATTERN_ON_MS; + return; + } + + if (!millisIntervalElapsed(now, lastLedChangeMs, nextLedChangeMs)) + { + return; + } + + if (patternActive) + { + if (ledOn) { - digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level) - nextLEDchange = HIGH_TIME_LED; + ledOn = false; + digitalWrite(KRAKE_LED_BUILTIN, LOW); + if (--patternPulsesRemaining == 0) + { + patternActive = false; + nextLedChangeMs = HEARTBEAT_OFF_MS; + } + else + { + nextLedChangeMs = PATTERN_OFF_MS; + } } else { - digitalWrite(LED_BUILTIN, LOW); // turn the LED on (HIGH is the voltage level) - nextLEDchange = LOW_TIME_LED; + ledOn = true; + digitalWrite(KRAKE_LED_BUILTIN, HIGH); + nextLedChangeMs = PATTERN_ON_MS; } - lastLEDtime = millis(); } -} // end LED wink + else + { + ledOn = !ledOn; + digitalWrite(KRAKE_LED_BUILTIN, ledOn ? HIGH : LOW); + nextLedChangeMs = ledOn ? HEARTBEAT_ON_MS : HEARTBEAT_OFF_MS; + } + lastLedChangeMs = now; +} diff --git a/Firmware/GPAD_API/GPAD_API/Wink.h b/Firmware/GPAD_API/GPAD_API/Wink.h index 3cd1b025..fe8e12a6 100644 --- a/Firmware/GPAD_API/GPAD_API/Wink.h +++ b/Firmware/GPAD_API/GPAD_API/Wink.h @@ -1,6 +1,9 @@ #ifndef WINK_H #define WINK_H +#include + void wink(void); +void queueWinkPattern(uint8_t pulseCount); #endif diff --git a/Firmware/GPAD_API/GPAD_API/alarm_api.cpp b/Firmware/GPAD_API/GPAD_API/alarm_api.cpp index f6fd3c85..70fbb7b3 100644 --- a/Firmware/GPAD_API/GPAD_API/alarm_api.cpp +++ b/Firmware/GPAD_API/GPAD_API/alarm_api.cpp @@ -19,6 +19,7 @@ */ #include "alarm_api.h" #include "gpad_utility.h" +#include "operator_settings.h" #include // here is the abstract "state" of the machine, @@ -83,11 +84,26 @@ void toggleMuted() currentlyMuted = !currentlyMuted; } +void unmute() +{ + clearMuteTimeout(); + setMuted(false); +} + void setMuteTimeoutMinutes(unsigned long minutes) { setMuted(true); + if (minutes == OPERATOR_MUTE_TIMEOUT_INFINITE_MINUTES) + { + // No deadline: remain muted until a manual unmute or a received `u` command. + clearMuteTimeout(); + return; + } + 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,10 +121,9 @@ bool serviceMuteTimeout() return false; } - if ((millis() - muteTimeoutStartMillis) >= muteTimeoutDurationMillis) + if (millisIntervalElapsed(millis(), muteTimeoutStartMillis, muteTimeoutDurationMillis)) { - clearMuteTimeout(); - setMuted(false); + unmute(); return true; } diff --git a/Firmware/GPAD_API/GPAD_API/alarm_api.h b/Firmware/GPAD_API/GPAD_API/alarm_api.h index 7b3b9a7d..c3a78356 100644 --- a/Firmware/GPAD_API/GPAD_API/alarm_api.h +++ b/Firmware/GPAD_API/GPAD_API/alarm_api.h @@ -49,6 +49,7 @@ int alarm(AlarmLevel level, char *str, Stream *serialport); void setMuted(bool muted); bool isMuted(); void toggleMuted(); +void unmute(); void setMuteTimeoutMinutes(unsigned long minutes); void clearMuteTimeout(); bool serviceMuteTimeout(); 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..5771cedf 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) { @@ -72,20 +74,26 @@ void processSerial(Stream *debugPort, Stream *inputPort, PubSubClient *client) buf[rlen] = '\0'; #if (GPAD_DEBUG > 0) - debugPort->print(F("I received: ")); - debugPort->print(rlen); - for (int i = 0; i < rlen; i++) + if (rlen == 0 || buf[0] != 'j') { - debugPort->print(buf[i]); + debugPort->print(F("I received: ")); + debugPort->print(rlen); + for (int i = 0; i < rlen; i++) + { + debugPort->print(buf[i]); + } + debugPort->println(); } - debugPort->println(); #endif if (rlen > 0) { - interpretBuffer(buf, rlen, debugPort, client); - requestAlarmRefresh(debugPort); - printAlarmState(debugPort); + const InterpretedCommand result = interpretBuffer(buf, rlen, debugPort); + applyInterpretedCommand(result, debugPort); + if (!result.responseIsJson) + { + printAlarmState(debugPort); + } processedCommand = true; } writeIndex = 0; @@ -100,6 +108,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.cpp b/Firmware/GPAD_API/GPAD_API/gpad_utility.cpp index ca6ecd46..220a7108 100644 --- a/Firmware/GPAD_API/GPAD_API/gpad_utility.cpp +++ b/Firmware/GPAD_API/GPAD_API/gpad_utility.cpp @@ -32,7 +32,7 @@ void printError(Stream *serialport) } void printInstructions(Stream *serialport) { - serialport->println(F("PubInv GPAD: enter command in form CDa (C is a char, D is a digit)")); + serialport->println(F("PubInv GPAD commands: I = system info, j = system info JSON, h = help, aN... = alarm")); } void printAlarmState(Stream *serialport) { diff --git a/Firmware/GPAD_API/GPAD_API/gpad_utility.h b/Firmware/GPAD_API/GPAD_API/gpad_utility.h index 62e5ef50..b276496c 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,22 @@ #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; +} void printError(Stream *serialport); void printInstructions(Stream *serialport); diff --git a/Firmware/GPAD_API/GPAD_API/mqtt_handler.cpp b/Firmware/GPAD_API/GPAD_API/mqtt_handler.cpp index a7e8e331..f8a5895e 100644 --- a/Firmware/GPAD_API/GPAD_API/mqtt_handler.cpp +++ b/Firmware/GPAD_API/GPAD_API/mqtt_handler.cpp @@ -1,5 +1,6 @@ #include "mqtt_handler.h" #include "debug_macros.h" +#include "spi_broker_mirror.h" #include #include @@ -33,6 +34,10 @@ bool queueMqtt(const char *topic, const char *payload, bool retain) return false; } + // SPI is an independent local transport: mirror every broker-bound message + // even while WiFi/MQTT are offline or the MQTT queue is full. + queueSpiBrokerMirror(topic, payload, retain); + for (uint8_t i = 0; i < MQTT_QUEUE_SIZE; i++) { if (!mqttQueue[i].active) 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..eb1820f7 --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/operator_settings.cpp @@ -0,0 +1,81 @@ +#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"; + const char *OPERATOR_PREF_REPEAT_SEC = "repeatSec"; + + 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); + } + + bool saveUShort(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.putUShort(key, static_cast(value)); + prefs.end(); + return written == sizeof(uint16_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); +} + +int loadAlarmRepeatSecondsSetting(int fallbackSeconds) +{ + Preferences prefs; + if (!prefs.begin(OPERATOR_PREF_NS, true)) { setSetupError(SETUP_ERROR_OPERATOR_PREFERENCES); return clampSetting(fallbackSeconds, OPERATOR_ALARM_REPEAT_MIN_SECONDS, OPERATOR_ALARM_REPEAT_MAX_SECONDS); } + const int safeFallback = clampSetting(fallbackSeconds, OPERATOR_ALARM_REPEAT_MIN_SECONDS, OPERATOR_ALARM_REPEAT_MAX_SECONDS); + const int seconds = prefs.getUShort(OPERATOR_PREF_REPEAT_SEC, static_cast(safeFallback)); + prefs.end(); + return clampSetting(seconds, OPERATOR_ALARM_REPEAT_MIN_SECONDS, OPERATOR_ALARM_REPEAT_MAX_SECONDS); +} + +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)); +} + +bool saveAlarmRepeatSecondsSetting(int seconds) +{ + return saveUShort(OPERATOR_PREF_REPEAT_SEC, clampSetting(seconds, OPERATOR_ALARM_REPEAT_MIN_SECONDS, OPERATOR_ALARM_REPEAT_MAX_SECONDS)); +} 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..0ea86427 --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/operator_settings.h @@ -0,0 +1,20 @@ +#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_INFINITE_MINUTES = 0; +const int OPERATOR_MUTE_TIMEOUT_MIN_MINUTES = OPERATOR_MUTE_TIMEOUT_INFINITE_MINUTES; +const int OPERATOR_MUTE_TIMEOUT_MAX_MINUTES = 60; +const int OPERATOR_ALARM_REPEAT_MIN_SECONDS = 1; +const int OPERATOR_ALARM_REPEAT_MAX_SECONDS = 300; +const int OPERATOR_ALARM_REPEAT_DEFAULT_SECONDS = 10; + +int loadVolumeSetting(int fallbackPercent); +int loadMuteTimeoutMinutesSetting(int fallbackMinutes); +int loadAlarmRepeatSecondsSetting(int fallbackSeconds); +bool saveVolumeSetting(int volumePercent); +bool saveMuteTimeoutMinutesSetting(int minutes); +bool saveAlarmRepeatSecondsSetting(int seconds); + +#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/GPAD_API/spi_broker_mirror.cpp b/Firmware/GPAD_API/GPAD_API/spi_broker_mirror.cpp new file mode 100644 index 00000000..5898db7b --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/spi_broker_mirror.cpp @@ -0,0 +1,112 @@ +#include "spi_broker_mirror.h" +#include "debug_macros.h" +#include +#include + +namespace +{ + const uint8_t SPI_MIRROR_QUEUE_SIZE = 8; + const uint8_t SPI_MIRROR_SS_PIN = 15; + const uint32_t SPI_MIRROR_CLOCK_HZ = 1000000; + const size_t SPI_MIRROR_TOPIC_CAPACITY = 96; + const size_t SPI_MIRROR_PAYLOAD_CAPACITY = 128; + + struct PendingSpiMirror + { + char topic[SPI_MIRROR_TOPIC_CAPACITY]; + char payload[SPI_MIRROR_PAYLOAD_CAPACITY]; + bool retain; + bool active; + }; + + SPIClass spiMirror(VSPI); + PendingSpiMirror spiMirrorQueue[SPI_MIRROR_QUEUE_SIZE]; + bool spiMirrorInitialized = false; + + void copyBounded(char *dest, size_t destLen, const char *src) + { + strncpy(dest, src, destLen - 1); + dest[destLen - 1] = '\0'; + } + + void transferByte(uint8_t value) + { + spiMirror.transfer(value); + } +} + +void initializeSpiBrokerMirror() +{ + if (spiMirrorInitialized) + { + return; + } + pinMode(SPI_MIRROR_SS_PIN, OUTPUT); + digitalWrite(SPI_MIRROR_SS_PIN, HIGH); + spiMirror.begin(SCK, MISO, MOSI, SPI_MIRROR_SS_PIN); + spiMirrorInitialized = true; +} + +bool queueSpiBrokerMirror(const char *topic, const char *payload, bool retain) +{ + if (topic == nullptr || payload == nullptr || topic[0] == '\0') + { + return false; + } + + for (uint8_t i = 0; i < SPI_MIRROR_QUEUE_SIZE; ++i) + { + if (!spiMirrorQueue[i].active) + { + copyBounded(spiMirrorQueue[i].topic, sizeof(spiMirrorQueue[i].topic), topic); + copyBounded(spiMirrorQueue[i].payload, sizeof(spiMirrorQueue[i].payload), payload); + spiMirrorQueue[i].retain = retain; + spiMirrorQueue[i].active = true; + return true; + } + } + + DBG_PRINTLN(F("SPI broker mirror queue full")); + return false; +} + +void serviceSpiBrokerMirror() +{ + if (!spiMirrorInitialized) + { + return; + } + + for (uint8_t i = 0; i < SPI_MIRROR_QUEUE_SIZE; ++i) + { + PendingSpiMirror &pending = spiMirrorQueue[i]; + if (!pending.active) + { + continue; + } + + const uint8_t topicLength = static_cast(strlen(pending.topic)); + const uint8_t payloadLength = static_cast(strlen(pending.payload)); + const uint8_t header[] = {'K', 'R', 'K', '1', pending.retain ? uint8_t(1) : uint8_t(0), topicLength, payloadLength}; + + spiMirror.beginTransaction(SPISettings(SPI_MIRROR_CLOCK_HZ, MSBFIRST, SPI_MODE0)); + digitalWrite(SPI_MIRROR_SS_PIN, LOW); + for (size_t j = 0; j < sizeof(header); ++j) + { + transferByte(header[j]); + } + for (uint8_t j = 0; j < topicLength; ++j) + { + transferByte(static_cast(pending.topic[j])); + } + for (uint8_t j = 0; j < payloadLength; ++j) + { + transferByte(static_cast(pending.payload[j])); + } + digitalWrite(SPI_MIRROR_SS_PIN, HIGH); + spiMirror.endTransaction(); + + pending.active = false; + return; + } +} diff --git a/Firmware/GPAD_API/GPAD_API/spi_broker_mirror.h b/Firmware/GPAD_API/GPAD_API/spi_broker_mirror.h new file mode 100644 index 00000000..24931e8e --- /dev/null +++ b/Firmware/GPAD_API/GPAD_API/spi_broker_mirror.h @@ -0,0 +1,13 @@ +#ifndef SPI_BROKER_MIRROR_H +#define SPI_BROKER_MIRROR_H + +#include + +// Broker mirror frame, transmitted MSB-first over the KRAKE VSPI controller: +// "KRK1", retain byte, topic length byte, payload length byte, topic, payload. +// Topic and payload bytes are the same bytes queued for MQTT publication. +void initializeSpiBrokerMirror(); +bool queueSpiBrokerMirror(const char *topic, const char *payload, bool retain = false); +void serviceSpiBrokerMirror(); + +#endif diff --git a/Firmware/GPAD_API/README.md b/Firmware/GPAD_API/README.md index 6695e6f9..bbe0884e 100644 --- a/Firmware/GPAD_API/README.md +++ b/Firmware/GPAD_API/README.md @@ -26,14 +26,19 @@ As the documentation says, you **do not** have to follow all steps in the CLI gu mentioned below, it is required to install the shell commands for the `make` command to function correctly. ### Build -In the this directory, there is a "make" file. If you have make installed, you can run "make". There are two targets: +In this directory, there is a `makefile`. If you have `make` installed, run `make` or `make build` to compile the firmware without requiring a connected device. The available targets are: - 1. run --- this does a compile and upload, and begins running a "monitor", which prints the output of the serial monitor and allows commands to be typed in, just as they are typically done in the Arduino IDE. - 2. monitor --- this does a reset and runs the monitor without doing a fully reset. +1. `build` --- compiles the firmware image. This is the default target used by `make`. +2. `upload` --- compiles and uploads the firmware image to a connected device. +3. `run` --- uploads the firmware image and begins running a monitor, which prints serial output and allows commands to be typed in, just as in the Arduino IDE. +4. `monitor` --- runs the serial monitor without uploading a new firmware image. These commands are implemented on the command line as: ``` + make + make build + make upload make run make monitor ``` @@ -58,3 +63,14 @@ Installing/flashing a new firmware image requires the device, Krake, to be set i 3. While still holding BOOT, press and release the RESET button once. 4. Release the BOOT button. 5. The chip is now in boot mode. You can run your flasher and the port should respond. + +# Runtime Connectivity and SPI Broker Mirror +WiFi credentials remain stored until explicitly cleared. Developers can clear saved WiFi credentials from the LCD `Developer Mode` menu or from the served settings page; the device then restarts into its normal WiFi setup flow. + +Every topic/payload pair queued for MQTT publication is also queued independently for transmission from the Krake ESP32 VSPI controller. This keeps the local SPI output available even when WiFi or MQTT is offline. The controller uses a 1 MHz, MSB-first, SPI mode 0 link with `SCLK=18`, `MISO=19`, `MOSI=23`, and chip-select `SS=15`. Each transfer is one frame: + +``` +"KRK1" | retain byte | topic length byte | payload length byte | topic bytes | payload bytes +``` + +The built-in LED retains its heartbeat and adds connection indicators: two short winks after WiFi connects and three short winks after MQTT connects. diff --git a/Firmware/GPAD_API/VERSIONING.md b/Firmware/GPAD_API/VERSIONING.md new file mode 100644 index 00000000..6fba119f --- /dev/null +++ b/Firmware/GPAD_API/VERSIONING.md @@ -0,0 +1,22 @@ +# 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. + +When a pull request is raised against `main`, the `Assign GPAD firmware PR version` +workflow increments the patch component on that pull request branch and records the pull +request number as SemVer build metadata. For example, raising PR `#123` after `0.58.0` +produces `0.58.1+pr.123` in the PR itself, before it is merged. + +Reopening the same pull request is idempotent: if its branch already records that PR +number, the workflow leaves the version unchanged. Version assignment is limited to +branches in this repository because GitHub does not grant the workflow permission to +push version commits to contributors' forks. + +For an intentional release, update `FIRMWARE_VERSION` in a pull request to the +desired `MAJOR.MINOR.PATCH` baseline. The next raised pull request resumes patch +increments from that baseline. + +The workflow pushes its isolated version commit to the open pull request branch, so it +does not require a direct push to `main`. 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..0f8b1aa4 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'] ] } @@ -72,6 +79,46 @@ } function toggleMenu() { const menu = byId('sideMenu'); if (menu) menu.classList.toggle('open'); } + function mountFooter() { + let footer = document.querySelector('footer.footer'); + if (!footer) { + footer = document.createElement('footer'); + footer.className = 'footer'; + document.body.appendChild(footer); + } + + let links = footer.querySelector('.footer-links'); + if (!links) { + links = document.createElement('div'); + links.className = 'footer-links'; + const message = footer.querySelector('#message'); + footer.insertBefore(links, message || footer.firstChild); + } + + if (!links.querySelector('[data-footer-link="pubinv"]')) { + const websiteLink = footer.querySelector('a[href="https://pubinv.org"]') || document.createElement('a'); + websiteLink.href = 'https://pubinv.org'; + websiteLink.target = '_blank'; + websiteLink.rel = 'noopener noreferrer'; + websiteLink.dataset.footerLink = 'pubinv'; + websiteLink.textContent = 'pubinv.org'; + links.appendChild(websiteLink); + } + + if (!links.querySelector('[data-footer-link="github"]')) { + const githubLink = document.createElement('a'); + githubLink.href = 'https://github.com/PubInv/krake'; + githubLink.target = '_blank'; + githubLink.rel = 'noopener noreferrer'; + githubLink.className = 'github-link'; + githubLink.dataset.footerLink = 'github'; + githubLink.setAttribute('aria-label', 'View KRAKE source code on GitHub'); + githubLink.title = 'View KRAKE on GitHub'; + githubLink.innerHTML = 'GitHub'; + links.appendChild(githubLink); + } + } + function renderNav(navTarget) { const sections = navSections.filter((section) => { if (section.id === 'developer') return state.developerUnlocked; @@ -86,7 +133,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,13 +157,14 @@ 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); } + mountFooter(); const menuToggle = byId('menuToggle'); if (menuToggle) menuToggle.addEventListener('click', toggleMenu); } 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..42117e09 100644 --- a/Firmware/GPAD_API/data/js/settings.js +++ b/Firmware/GPAD_API/data/js/settings.js @@ -9,6 +9,17 @@ const node = KrakeUI.byId(id); return node ? node.value : ''; } + function populateMuteDurations() { + const select = KrakeUI.byId('muteTimeoutMinutes'); + if (!select) return; + for (let minutes = 1; minutes <= 60; minutes += 1) { + const option = document.createElement('option'); + option.value = String(minutes); + option.textContent = minutes + (minutes === 1 ? ' minute' : ' minutes'); + select.appendChild(option); + } + } + populateMuteDurations(); async function loadWifi() { const data = await KrakeUI.getJson('/wifi'); setInputValue('ssid', data.ssid || ''); @@ -23,14 +34,28 @@ 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(typeof data.muteTimeoutMinutes === 'number' ? data.muteTimeoutMinutes : 5)); + setInputValue('alarmRepeatSeconds', String(data.alarmRepeatSeconds || 10)); KrakeUI.setText('muteStatus', data.muted ? 'Muted' : 'Unmuted'); KrakeUI.setText('alarmTopic', data.publishTopic || '-'); KrakeUI.setText('ackTopic', data.subscribeTopic || '-'); @@ -40,6 +65,16 @@ 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')); + const alarmRepeatSeconds = Number(getInputValue('alarmRepeatSeconds')); + if (!Number.isInteger(volume) || volume < 1 || volume > 100) return KrakeUI.showMessage('Volume must be between 1 and 100%.', true); + if (!Number.isInteger(muteTimeoutMinutes) || muteTimeoutMinutes < 0 || muteTimeoutMinutes > 60) return KrakeUI.showMessage('Mute duration must be Infinite or between 1 and 60 minutes.', true); + if (!Number.isInteger(alarmRepeatSeconds) || alarmRepeatSeconds < 1 || alarmRepeatSeconds > 300) return KrakeUI.showMessage('Alarm repeat delay must be between 1 and 300 seconds.', true); + try { await KrakeUI.postForm('/settings/sound', { volume, muteTimeoutMinutes, alarmRepeatSeconds }); 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 +83,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..67e80215 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,16 @@

Sound

+ + + +

Infinite stays muted until a manual unmute or a received u command.

+ + + +
Current status: -
Mute command topics: -
Last command: -
@@ -55,8 +65,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..ae1cd62e 100644 --- a/Firmware/GPAD_API/data/style.css +++ b/Firmware/GPAD_API/data/style.css @@ -2,212 +2,1004 @@ --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-links { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.footer a { + color: var(--accent-strong); + text-decoration: none; + font-weight: 700; +} + +.github-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: #1f2d25; + color: #fff !important; + box-shadow: 0 4px 12px rgba(22, 50, 34, 0.18); + transition: transform 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.github-link:hover, +.github-link:focus-visible { + background: var(--accent-strong); + box-shadow: 0 6px 16px rgba(22, 50, 34, 0.28); + transform: translateY(-2px); +} + +.github-icon { + width: 22px; + height: 22px; } -/* BUTTONS */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.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/makefile b/Firmware/GPAD_API/makefile index b12d11e6..642e4e17 100644 --- a/Firmware/GPAD_API/makefile +++ b/Firmware/GPAD_API/makefile @@ -14,16 +14,24 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # +PIO ?= pio -run: - pio run -t upload \ - && pio device monitor -b 115200 +.PHONY: build run upload monitor build-fs uploadfs + +build: + $(PIO) run + +run: upload + $(PIO) device monitor -b 115200 + +upload: + $(PIO) run -t upload monitor: - pio device monitor -b 115200 + $(PIO) device monitor -b 115200 build-fs: - pio run --target buildfs + $(PIO) run --target buildfs uploadfs: - pio run --target uploadfs + $(PIO) run --target uploadfs 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 "