diff --git a/src/SplitFlapDisplay.cpp b/src/SplitFlapDisplay.cpp index 67aa732..ebe0fe2 100644 --- a/src/SplitFlapDisplay.cpp +++ b/src/SplitFlapDisplay.cpp @@ -160,18 +160,7 @@ void SplitFlapDisplay::writeChar(char inputChar, float speed) { moveTo(targetPositions, speed); } -String sanitizeInput(const String &input) { - String sanitized = input; - - // Replace problematic characters - sanitized.replace("'", "'\\'"); - sanitized.replace("%", "%%"); - - return sanitized; -} - void SplitFlapDisplay::writeString(String inputString, float speed, bool centering) { - inputString = sanitizeInput(inputString); String displayString = inputString.substring(0, numModules); if (centering) { diff --git a/src/SplitFlapDisplay.ino b/src/SplitFlapDisplay.ino index b27d393..c8cb9ae 100644 --- a/src/SplitFlapDisplay.ino +++ b/src/SplitFlapDisplay.ino @@ -18,7 +18,9 @@ JsonSettings settings = JsonSettings("config", { {"name", JsonSetting("My Display")}, {"mdns", JsonSetting("splitflap")}, {"otaPass", JsonSetting("")}, - {"timezone", JsonSetting("Etc/UTC")}, + {"timezone", JsonSetting("UTC0")}, + {"dateFormat", JsonSetting("{dd}-{mm}-{yy}")}, + {"timeFormat", JsonSetting("{HH}:{mm}")}, // Wifi Settings {"ssid", JsonSetting("")}, {"password", JsonSetting("")}, @@ -82,9 +84,8 @@ void setup() { splitflapMqtt.setup(); splitflapMqtt.setDisplay(&display); display.setMqtt(&splitflapMqtt); - display.homeToString(""); - display.writeString("OK"); + display.homeToString("OK"); delay(250); display.writeString(""); } @@ -138,23 +139,14 @@ void multiInputMode() { void dateMode() { if (millis() - webServer.getLastCheckDateTime() > webServer.getDateCheckInterval()) { webServer.setLastCheckDateTime(millis()); - String currentDay = webServer.getCurrentDay(); - String dayPrefix = webServer.getDayPrefix(3); - - String outputString = " "; - switch (display.getNumModules()) { - case 2: outputString = currentDay; break; - case 3: outputString = dayPrefix; break; - case 4: outputString = " " + currentDay + " "; break; - case 5: outputString = dayPrefix + currentDay; break; - case 6: outputString = dayPrefix + " " + currentDay; break; - case 7: outputString = dayPrefix + " " + currentDay; break; - case 8: outputString = dayPrefix + currentDay + webServer.getMonthPrefix(3); break; - default: break; - } - if (outputString != webServer.getWrittenString()) { - display.writeString(outputString, MAX_RPM, webServer.getCentering()); - webServer.setWrittenString(outputString); + + String format = settings.getString("dateFormat"); + String strftimeFormat = convertToStrftime(format); + String result = renderDate(strftimeFormat); + + if (result.length() <= display.getNumModules() && result != webServer.getWrittenString()) { + display.writeString(result, MAX_RPM); + webServer.setWrittenString(result); } } } @@ -162,24 +154,18 @@ void dateMode() { void timeMode() { if (millis() - webServer.getLastCheckDateTime() > webServer.getDateCheckInterval()) { webServer.setLastCheckDateTime(millis()); - String currentHour = webServer.getCurrentHour(); - String currentMinute = webServer.getCurrentMinute(); - String outputString = " "; - - switch (display.getNumModules()) { - case 2: outputString = currentMinute; break; - case 3: outputString = " " + currentMinute; break; - case 4: outputString = currentHour + "" + currentMinute; break; - case 5: outputString = currentHour + " " + currentMinute; break; - case 6: outputString = " " + currentHour + " " + currentMinute; break; - case 7: outputString = " " + currentHour + " " + currentMinute + " "; break; - case 8: outputString = " " + currentHour + currentMinute + " "; break; - default: break; - } - if (outputString != webServer.getWrittenString()) { - display.writeString(outputString, MAX_RPM, webServer.getCentering()); - webServer.setWrittenString(outputString); + // Get user-friendly format from settings (fallback to "HH:mm") + String userFormat = settings.getString("timeFormat").length() > 0 ? settings.getString("timeFormat") : "HH:mm"; + + // Convert to strftime-compatible format + String strftimeFormat = convertToStrftime(userFormat); + String result = renderTime(strftimeFormat); + + // Write to display if it changed + if (result != webServer.getWrittenString()) { + display.writeString(result, MAX_RPM); + webServer.setWrittenString(result); } } } @@ -240,3 +226,61 @@ String extractFromCSV(String str, int index) { return str.substring(startIndex, endIndex); } + +String renderDate(const String &format) { + char buf[64]; + time_t now = time(nullptr); + struct tm *timeinfo = localtime(&now); + + strftime(buf, sizeof(buf), format.c_str(), timeinfo); + + return trimToModuleCount(String(buf), display.getNumModules()); +} + +String renderTime(const String &format) { + char buf[64]; + time_t now = time(nullptr); + struct tm *timeinfo = localtime(&now); + + strftime(buf, sizeof(buf), format.c_str(), timeinfo); + + return trimToModuleCount(String(buf), display.getNumModules()); +} + +String trimToModuleCount(const String &str, int maxLen) { + return str.length() > maxLen ? str.substring(0, maxLen) : str; +} + +String convertToStrftime(String userFormat) { + struct FormatToken + { + const char *token; + const char *strftime; + }; + + FormatToken tokens[] = { + // Date formats + {"{yyyy}", "%Y"}, // 4-digit year (e.g. 2025) + {"{dddd}", "%A"}, // Full weekday name (e.g. Monday) + {"{mmmm}", "%B"}, // Full month name (e.g. January) + {"{ddd}", "%a"}, // Abbreviated weekday name (e.g. Mon) + {"{mmm}", "%b"}, // Abbreviated month name (e.g. Apr) + {"{dd}", "%d"}, // 2-digit day of month, zero-padded (01–31) + {"{mm}", "%m"}, // 2-digit month number, zero-padded (01–12) + {"{yy}", "%y"}, // 2-digit year (e.g. 25) + {"{ww}", "%V"}, // ISO 8601 week number (01–53) + {"{D}", "%j"}, // Day of the year (001–366) + + // Time formats + {"{HH}", "%H"}, // Hours (24-hour clock, 00–23) + {"{hh}", "%I"}, // Hours (12-hour clock, 01–12) + {"{MM}", "%M"}, // Minutes (00–59) + {"{AMPM}", "%p"}, // AM or PM + }; + + for (auto &t : tokens) { + userFormat.replace(t.token, t.strftime); + } + + return userFormat; +} diff --git a/src/SplitFlapMqtt.cpp b/src/SplitFlapMqtt.cpp index 8b04d19..452d144 100644 --- a/src/SplitFlapMqtt.cpp +++ b/src/SplitFlapMqtt.cpp @@ -37,27 +37,27 @@ void SplitFlapMqtt::setup() { void SplitFlapMqtt::connectToMqtt() { if (! mqttClient.connected()) { Serial.println("[MQTT] Attempting to connect..."); - String clientId = "SplitFlap-" + settings.getString("mdns"); + String mdns = settings.getString("mdns"); + String name = settings.getString("name"); + if (mqttUser.length() > 0) { - mqttClient.connect(clientId.c_str(), mqttUser.c_str(), mqttPass.c_str()); + mqttClient.connect(mdns.c_str(), mqttUser.c_str(), mqttPass.c_str()); } else { - mqttClient.connect(clientId.c_str()); + mqttClient.connect(mdns.c_str()); } if (mqttClient.connected()) { Serial.println("[MQTT] Connected to broker"); - String mdns = settings.getString("mdns"); - // clang-format off String payload_text = "{" - "\"name\":\"" + settings.getString("name") + "\"," - "\"unique_id\":\"splitflap_text_" + mdns + "\"," + "\"name\":\"Display\"," + "\"unique_id\":\"text_" + mdns + "\"," "\"command_topic\":\"" + topic_command + "\"," "\"availability_topic\":\"" + topic_avail + "\"," "\"device\":{" "\"identifiers\":[\"splitflap_" + mdns + "\"]," - "\"name\":\"" + settings.getString("name") + "\"," + "\"name\":\"" + name + "\"," "\"manufacturer\":\"SplitFlap\"," "\"model\":\"SplitFlap Display\"," "\"sw_version\":\"1.0.0\"" @@ -65,15 +65,14 @@ void SplitFlapMqtt::connectToMqtt() { "}"; String payload_sensor = "{" - "\"name\":\"" + settings.getString("name") + " (Sensor)\"," - "\"unique_id\":\"splitflap_sensor_" + mdns + "\"," + "\"name\":\"Currently Displayed\"," + "\"unique_id\":\"sensor_" + mdns + "\"," "\"state_topic\":\"" + topic_state + "\"," "\"availability_topic\":\"" + topic_avail + "\"," - "\"device_class\":\"none\"," "\"entity_category\":\"diagnostic\"," "\"device\":{" "\"identifiers\":[\"splitflap_" + mdns + "\"]," - "\"name\":\"" + settings.getString("name") + "\"," + "\"name\":\"" + name + "\"," "\"manufacturer\":\"SplitFlap\"," "\"model\":\"SplitFlap Display\"," "\"sw_version\":\"1.0.0\"" @@ -86,6 +85,7 @@ void SplitFlapMqtt::connectToMqtt() { mqttClient.publish(topic_state.c_str(), "", true); mqttClient.publish(topic_config_text.c_str(), payload_text.c_str(), true); + mqttClient.publish(topic_config_sensor.c_str(), payload_sensor.c_str(), true); } else { Serial.println("[MQTT] Failed to connect"); } diff --git a/src/SplitFlapWebServer.cpp b/src/SplitFlapWebServer.cpp index f041e56..ff8d952 100644 --- a/src/SplitFlapWebServer.cpp +++ b/src/SplitFlapWebServer.cpp @@ -521,9 +521,5 @@ String SplitFlapWebServer::decodeURIComponent(String encodedString) { decodedString.replace("%7D", "}"); // right brace decodedString.replace("%7E", "~"); // tilde - // Handle percent-encoded values for characters beyond basic ASCII (e.g., - // extended Unicode) - decodedString.replace("%", ""); - return decodedString; } diff --git a/src/web/custom-text.html b/src/web/custom-text.html deleted file mode 100644 index b62ece0..0000000 --- a/src/web/custom-text.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - - - - - - - -
-
- < -

-
- -
- - -
- -
- - -
- -
- -
- -
- -
- - -
- -
- - -
-
- -
- - -
- - - -
-
- - - diff --git a/src/web/index.css b/src/web/index.css index f1d8c73..7984827 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1 +1,11 @@ @import "tailwindcss"; + +input[type="number"].no-buttons::-webkit-outer-spin-button, +input[type="number"].no-buttons::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"].no-buttons { + -moz-appearance: textfield; /* Firefox */ +} diff --git a/src/web/index.html b/src/web/index.html index 680fe70..e7bab3c 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -14,36 +14,169 @@
-
-

+
+

- ModeSelect Mode: + + + + + + Settings
{ Alpine.data("page", (type) => ({ get header() { - return (this.settings.name || "Split Flap") + " " + type; + return this.settings.name || "Split Flap"; }, loading: { settings: true, + timezones: true, }, - + saving: false, dialog: { show: false, message: "", type: null, }, - - settings: {}, + settings: { + mode: 2, + dateFormat: "ddd dd/MM", + timeFormat: "HH:mm", + }, errors: {}, + timezones: {}, + + // Control page specific + singleMode: true, + singleWord: "", + multiWord: "", + multiWords: [], + delay: 1, + centerText: false, + + get processing() { + return ( + this.saving || this.loading.settings || this.loading.timezones + ); + }, + + get addressArray() { + return ( + this.settings.moduleAddresses + ?.split(",") + .map((s) => s.trim()) || [] + ); + }, + setAddress(index, value) { + const arr = this.addressArray; + arr[index] = value; + this.settings.moduleAddresses = arr.join(","); + }, + + get offsetArray() { + return ( + this.settings.moduleOffsets?.split(",").map((s) => s.trim()) || + [] + ); + }, + setOffset(index, value) { + const arr = this.offsetArray; + arr[index] = value; + this.settings.moduleOffsets = arr.join(","); + }, init() { this.loadSettings(); + if (type === "Settings") { + this.loadTimezones(); + } }, loadSettings() { fetch("/settings") - .then((response) => response.json()) + .then((res) => res.json()) .then((data) => { - Object.keys(data).forEach((key) => { - this.settings[key] = data[key]; - }); + Object.assign(this.settings, data); }) - .catch((error) => + .catch(() => + this.showDialog("Failed to load settings", "error", true), + ) + .finally(() => { + this.loading.settings = false; + }); + }, + + loadTimezones() { + fetch("/timezones.json") + .then((res) => res.json()) + .then((data) => { + this.timezones = data; + }) + .catch(() => this.showDialog( - "Failed to load settings. Refresh the page.", + "Failed to load timezones. Refresh the page.", "error", true, ), ) - .finally(() => (this.loading.settings = false)); + .finally(() => (this.loading.timezones = false)); }, - showDialog(message, type, persistent = false) { + updateDisplay() { + if (this.settings.mode === 6) { + if (this.delay < 1) { + return this.showDialog( + "Delay must be at least 1 second.", + "error", + ); + } + + if (this.singleMode && this.singleWord.trim() === "") { + return this.showDialog( + "Single word cannot be empty.", + "error", + ); + } + + if (!this.singleMode && this.multiWords.length === 0) { + return this.showDialog( + "Word list cannot be empty.", + "error", + ); + } + } + + fetch("/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: this.settings.mode }), + }); + + if (this.settings.mode === 6) { + fetch("/text", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: this.singleMode ? "single" : "multiple", + words: this.singleMode + ? [this.singleWord] + : this.multiWords, + delay: this.delay, + center: this.centerText, + }), + }) + .then((res) => res.json()) + .then((res) => this.showDialog(res.message, res.type)) + .catch((err) => this.showDialog(err.message, "error")); + } else { + this.showDialog("Mode updated successfully.", "success"); + } + }, + + addWord() { + if (this.multiWord.trim() !== "") { + this.multiWords.push(this.multiWord.trim()); + } + this.multiWord = ""; + }, + + removeWord(index) { + this.multiWords.splice(index, 1); + }, + + save() { + this.saving = true; + this.errors = {}; + + fetch("/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.settings), + }) + .then((res) => res.json()) + .then((data) => { + this.errors = data.errors || {}; + this.showDialog(data.message, data.type, data.persistent); + if (data.redirect) { + setTimeout(() => { + window.location.href = data.redirect; + }, 10000); + } + }) + .catch(() => + this.showDialog("Failed to save settings.", "error"), + ) + .finally(() => (this.saving = false)); + }, + + reset() { + if ( + confirm("Are you sure you want to reset settings to defaults?") + ) { + fetch("/settings/reset", { method: "POST" }) + .then((res) => res.json()) + .then((data) => { + this.showDialog( + data.message, + data.type, + data.persistent, + ); + this.loadSettings(); + }) + .catch(() => { + this.showDialog("Failed to reset settings.", "error"); + }); + } + }, + + showDialog(message, type = "success", persistent = false) { this.dialog.message = message; this.dialog.type = type; this.dialog.show = true; + if (!persistent) { setTimeout(() => (this.dialog.show = false), 3000); } }, })); + + Alpine.data("helpModal", () => ({ + visible: false, + title: "", + content: "", + + open({ title, content }) { + this.title = title; + this.content = content; + this.visible = true; + }, + + close() { + this.visible = false; + this.title = ""; + this.content = ""; + }, + })); }); Alpine.start(); diff --git a/src/web/mode.html b/src/web/mode.html deleted file mode 100644 index 543b59e..0000000 --- a/src/web/mode.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - -
-
- < -

-
- - - - - -
- -
- - diff --git a/src/web/settings.html b/src/web/settings.html index 111fca8..995bafa 100644 --- a/src/web/settings.html +++ b/src/web/settings.html @@ -14,85 +14,94 @@
-
- < + + -

-
+ + +

General Settings

- - -
- - - -
- - - -
+
+
+ + +
+
+
+ + +
+
+
- +
+
+
+ + +
+
+

Wi-Fi Settings

- - -
+
+
+ + +
+
+
+ + +
+
+
- +

MQTT Settings +

- - - - - +
+
+ + +
- - +
+ + +
+
- - +
+
+ + +
+
+ + +
+

Hardware Settings -

- - -
+ + - - +
+
+ + +
+
+
+ + +
+
- - + +
+ +
- - -
- - - + +
+ +
- - -
- - - -
- - - -
- - - -
- - - -
- - + + +
+
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
- + + + +
-
+
- Debugging Info - + Back -
-

-                
+ +
-
+ + +
+
+

+
+ +
+