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:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No words added yet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-
-
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
-
+
+
+