commit ddf764b65860f3349ba8262c5a10e46b88dbbd73 Author: Tomislav Kopić Date: Wed Nov 6 21:20:12 2024 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64727e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +config.h \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d8cb326 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "string": "cpp" + } +} \ No newline at end of file diff --git a/include/README b/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..2593a33 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..2c1a82d --- /dev/null +++ b/platformio.ini @@ -0,0 +1,18 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:d1_mini_lite] +platform = espressif8266 +board = d1_mini_lite +framework = arduino +lib_deps = + bblanchon/ArduinoJson@^7.2.0 + adafruit/Adafruit SSD1306@^2.5.13 + arduino-libraries/NTPClient@^3.2.1 diff --git a/src/Bitmaps/Clear.png b/src/Bitmaps/Clear.png new file mode 100644 index 0000000..4c25681 Binary files /dev/null and b/src/Bitmaps/Clear.png differ diff --git a/src/Bitmaps/Drizzle.png b/src/Bitmaps/Drizzle.png new file mode 100644 index 0000000..8eabb1e Binary files /dev/null and b/src/Bitmaps/Drizzle.png differ diff --git a/src/Bitmaps/Fog.png b/src/Bitmaps/Fog.png new file mode 100644 index 0000000..e729881 Binary files /dev/null and b/src/Bitmaps/Fog.png differ diff --git a/src/Bitmaps/Hail.png b/src/Bitmaps/Hail.png new file mode 100644 index 0000000..dc48ca5 Binary files /dev/null and b/src/Bitmaps/Hail.png differ diff --git a/src/Bitmaps/Rain.png b/src/Bitmaps/Rain.png new file mode 100644 index 0000000..0429782 Binary files /dev/null and b/src/Bitmaps/Rain.png differ diff --git a/src/Bitmaps/Snow.png b/src/Bitmaps/Snow.png new file mode 100644 index 0000000..c23f90a Binary files /dev/null and b/src/Bitmaps/Snow.png differ diff --git a/src/Bitmaps/Thunderstorms.png b/src/Bitmaps/Thunderstorms.png new file mode 100644 index 0000000..188741e Binary files /dev/null and b/src/Bitmaps/Thunderstorms.png differ diff --git a/src/bitmaps.h b/src/bitmaps.h new file mode 100644 index 0000000..835215b --- /dev/null +++ b/src/bitmaps.h @@ -0,0 +1,71 @@ +// 'clear', 16x16px +const unsigned char bitmap_clear [] PROGMEM = { + 0x01, 0x80, 0x01, 0x80, 0x31, 0x8c, 0x38, 0x1c, 0x1f, 0xf8, 0x0f, 0xf0, 0x0c, 0x30, 0xec, 0x37, + 0xec, 0x37, 0x0c, 0x30, 0x0f, 0xf0, 0x1f, 0xf8, 0x38, 0x1c, 0x31, 0x8c, 0x01, 0x80, 0x01, 0x80 +}; + +// 'cloudy', 16x16px +const unsigned char bitmap_cloudy [] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xf8, 0x0f, 0x9e, 0x1f, 0xcf, 0x78, 0xf3, + 0xf0, 0x7b, 0xc0, 0x1f, 0xc0, 0x1f, 0xff, 0xf8, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +}; +// 'fog', 16x16px +const unsigned char bitmap_fog [] PROGMEM = { + 0x00, 0x00, 0x78, 0x1e, 0xff, 0xff, 0xef, 0xf7, 0x00, 0x00, 0xfe, 0x7f, 0xff, 0xff, 0x03, 0xc0, + 0x7c, 0x3e, 0xff, 0xff, 0xcf, 0xf3, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x80, 0x00, 0x00 +}; + +// 'rain', 16x16px +const unsigned char bitmap_rain [] PROGMEM = { + 0x0f, 0x80, 0x1f, 0xc0, 0x38, 0xf8, 0x70, 0x7c, 0xf0, 0x0c, 0xe0, 0x0f, 0xc0, 0x07, 0xc0, 0x03, + 0xc0, 0x03, 0xff, 0xff, 0x7f, 0xfe, 0x66, 0x33, 0xe6, 0x73, 0xff, 0xff, 0xff, 0xfe, 0x73, 0x38 +}; + +// 'snow', 16x16px +const unsigned char bitmap_snow [] PROGMEM = { + 0x01, 0x80, 0x07, 0xe0, 0x1f, 0xf8, 0x7b, 0xde, 0x79, 0x9e, 0x7d, 0xbe, 0x7f, 0xfe, 0x67, 0xe6, + 0x67, 0xe6, 0x7f, 0xfe, 0x7d, 0xbe, 0x79, 0x9e, 0x7b, 0xde, 0x1f, 0xf8, 0x07, 0xe0, 0x01, 0x80 +}; + +// 'thunderstorm', 16x16px +const unsigned char bitmap_thunderstorm [] PROGMEM = { + 0x07, 0xf0, 0x06, 0x60, 0x0e, 0x60, 0x0c, 0xc0, 0x0c, 0xf8, 0x18, 0xfc, 0x18, 0x18, 0x38, 0x38, + 0x3f, 0xf0, 0x1f, 0xe0, 0x03, 0xe0, 0x03, 0xc0, 0x03, 0xc0, 0x03, 0x80, 0x07, 0x00, 0x07, 0x00 +}; + +// 'drizzle', 16x16px +const unsigned char bitmap_drizzle [] PROGMEM = { + 0x00, 0x00, 0x06, 0x00, 0x36, 0xc0, 0x3f, 0x80, 0x1f, 0xf0, 0x7b, 0xb8, 0x1e, 0x18, 0x18, 0x0e, + 0x18, 0x06, 0x19, 0x32, 0x0f, 0xfe, 0x07, 0xfc, 0x01, 0xf0, 0x03, 0xf0, 0x00, 0xc0, 0x00, 0x00 +}; + +// Array of all bitmaps for convenience +const unsigned char* bitmap_icons[7] = { + bitmap_clear, + bitmap_cloudy, + bitmap_fog, + bitmap_rain, + bitmap_snow, + bitmap_drizzle, + bitmap_thunderstorm, +}; + +const unsigned char temperature_icon [] PROGMEM = { + 0x1e, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x3f, 0x00, + 0x3f, 0x00, 0x1e, 0x00 +}; + +const unsigned char humidity_icon [] PROGMEM = { + 0x0c, 0x00, 0x1e, 0x00, 0x1e, 0x00, 0x3f, 0x00, 0x7f, 0x80, 0x7f, 0x80, 0x7f, 0x80, 0x7f, 0x80, + 0x3f, 0x00, 0x3f, 0x00 +}; + +const unsigned char wind_icon [] PROGMEM = { + 0x00, 0x00, 0x07, 0x00, 0x07, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x07, 0x80, + 0x07, 0x00, 0x00, 0x00 +}; + +const unsigned char pressure_icon [] PROGMEM = { + 0x1e, 0x00, 0x3f, 0x00, 0x73, 0x80, 0xe7, 0xc0, 0xce, 0xc0, 0xdc, 0xc0, 0xe9, 0xc0, 0x73, 0x80, + 0x3f, 0x00, 0x1e, 0x00 +}; diff --git a/src/example_config.h b/src/example_config.h new file mode 100644 index 0000000..60fa796 --- /dev/null +++ b/src/example_config.h @@ -0,0 +1,15 @@ +// OpenWeatherMap settings +#define LOCATION "" +#define API_KEY "" + +// OLED display settings +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 +#define OLED_RESET -1 +#define FRAMERATE 8 + +// Buttons and buzzer +#define PIN_BTN_L 12 // D6 +#define PIN_BTN_M 13 // D7 +#define PIN_BTN_R 15 // D8 +#define PIN_BUZZER 0 // D3 \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ee9cf09 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,333 @@ +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "bitmaps.h" +#include "netman.h" + +// Init display and wifi +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); +netman netman(display); +WiFiClient client; + +// Define NTP Client to get time +WiFiUDP ntpUDP; +NTPClient timeClient(ntpUDP, "pool.ntp.org", 3600); + +// Define these in config.h +const String Location = LOCATION; +const String API_Key = API_KEY; + +void capitalizeFirstLetter(char* str) { + if (str != nullptr && *str != '\0') { + str[0] = toupper(str[0]); + } +} + +void beep(int buzz) { + tone(PIN_BUZZER, buzz, 100); + delay(100); + noTone(PIN_BUZZER); +} + +void initSystems() { + pinMode(PIN_BTN_L, INPUT); + pinMode(PIN_BTN_M, INPUT); + pinMode(PIN_BTN_R, INPUT); + pinMode(PIN_BUZZER, OUTPUT); + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, HIGH); + // Initialize the SSD1306 display + if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Adjust I2C address if needed + for(;;); // Don't proceed, loop forever + } + + // Rotate the display 180 degrees + display.setRotation(2); + + // Clear the display buffer + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(WHITE); + display.setCursor(0, 0); + display.display(); +} + +// Timers for dynamic updates +unsigned long lastWeatherUpdate = 0; +unsigned long lastDisplayUpdate = 0; +const unsigned long weatherUpdateInterval = 1800000; // 30 minutes in milliseconds +const unsigned long displayOffInterval = 300000; // 5 minutes in milliseconds for power-saving mode +bool is_display_off = false; + +void commonButtonHandler() { + unsigned long currentMillis = millis(); + static unsigned long leftPressStart = 0; + static unsigned long middlePressStart = 0; + static unsigned long rightPressStart = 0; + + static bool leftBeeped = false; + static bool middleBeeped = false; + static bool rightBeeped = false; + + bool leftPressed = (digitalRead(PIN_BTN_L) == HIGH); + bool middlePressed = (digitalRead(PIN_BTN_M) == HIGH); + bool rightPressed = (digitalRead(PIN_BTN_R) == HIGH); + + // Short press detection + if (leftPressed) { + if ((currentMillis - leftPressStart > 50)) { // Debounce delay + if (!leftBeeped) { + beep(1000); // Play beep sound + leftBeeped = true; // Set beeped state + // Handle left short press action here + } + } + } else { + leftPressStart = currentMillis; // Reset the timer if button is not pressed + leftBeeped = false; // Reset beeped state + } + + if (middlePressed) { + if ((currentMillis - middlePressStart > 50)) { // Debounce delay + if (!middleBeeped) { + beep(1000); // Play beep sound + middleBeeped = true; // Set beeped state + // Handle middle short press action here + } + } + } else { + middlePressStart = currentMillis; // Reset the timer if button is not pressed + middleBeeped = false; // Reset beeped state + } + + if (rightPressed) { + if ((currentMillis - rightPressStart > 50)) { // Debounce delay + if (!rightBeeped) { + beep(1000); // Play beep sound + rightBeeped = true; // Set beeped state + if (is_display_off) { + display.ssd1306_command(SSD1306_DISPLAYON); // Turn on display + is_display_off = false; + beep(1300); + } + } + } + } else { + rightPressStart = currentMillis; // Reset the timer if button is not pressed + rightBeeped = false; // Reset beeped state + } + + // Long press detection + if (leftPressed && (currentMillis - leftPressStart > 2000)) { + if (!leftBeeped) { + beep(1000); // Play beep sound + leftBeeped = true; // Set beeped state + // Handle left long press action here + } + } + + if (middlePressed && (currentMillis - middlePressStart > 2000)) { + if (!middleBeeped) { + beep(1000); // Play beep sound + middleBeeped = true; // Set beeped state + // Handle middle long press action here + } + } + + if (rightPressed && (currentMillis - rightPressStart > 2000)) { + if (!is_display_off) { + beep(1300); + display.ssd1306_command(SSD1306_DISPLAYOFF); // Turn off display + is_display_off = true; + beep(1000); + } + } + + // Combination of Left and Middle long press + if (leftPressed && middlePressed && + (currentMillis - leftPressStart > 2000) && (currentMillis - middlePressStart > 2000)) { + ESP.restart(); + } +} + +void powerSaveCheck() { + int currentHour = timeClient.getHours(); + if ((currentHour >= 23 || currentHour < 6) && !is_display_off) { + display.ssd1306_command(SSD1306_DISPLAYOFF); // Turn off display for power-saving + is_display_off = true; + lastDisplayUpdate = millis(); // Track when display was turned off + } else if (is_display_off && (millis() - lastDisplayUpdate > displayOffInterval)) { + display.ssd1306_command(SSD1306_DISPLAYON); // Turn display back on + is_display_off = false; + } +} + +int icon = 0; +bool fetchWeatherData() { + // Get current time + time_t epochTime = timeClient.getEpochTime(); + struct tm *ptm = gmtime((time_t *)&epochTime); + int currentDay = ptm->tm_mday; + int currentMonth = ptm->tm_mon + 1; + int currentYear = ptm->tm_year + 1900; + HTTPClient http; + + int attempt; + bool dataFetched = false; + + // Request current weather + for (attempt = 0; attempt < 3; attempt++) { + // Request current weather + http.begin(client, "http://api.openweathermap.org/data/2.5/weather?q=" + Location + "&APPID=" + API_Key); + int httpCode = http.GET(); + if (httpCode > 0) { + String payload = http.getString(); + DynamicJsonDocument doc(1024); + + DeserializationError error = deserializeJson(doc, payload); + if (!error) { + const char* state = doc["weather"][0]["description"]; + capitalizeFirstLetter(const_cast(state)); + + float temp = doc["main"]["temp"].as() - 273.15; + int humidity = doc["main"]["humidity"]; + float pressure = doc["main"]["pressure"].as() / 1000; + float wind_speed = doc["wind"]["speed"].as(); + + int statusSize = strlen(state) > 12 ? 1 : 2; + + // Display data + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.printf("%d-%d-%d\r\n", currentYear, currentMonth, currentDay); + display.setCursor(0, 9); + display.printf("%s", Location.c_str()); + display.setTextSize(statusSize); + display.setCursor(0, 21); + display.printf("%s\r\n", state); + display.setCursor(0, 39); + display.setTextSize(1); + display.printf(" %5.2f C %d%%\r\n", temp, humidity); + display.drawRect(43, 39, 3, 3, WHITE); // Degree symbol + display.setCursor(0, 52); + display.printf(" %.3fbar %.1fm/s \r\n", pressure, wind_speed); + display.drawLine(0, 18, 127, 18, 1); + display.drawLine(65, 18, 65, 0, 1); + display.drawBitmap(0, 38, temperature_icon, 10, 10, WHITE); + display.drawBitmap(74, 38, humidity_icon, 10, 10, WHITE); + display.drawBitmap(0, 51, pressure_icon, 10, 10, WHITE); + display.drawBitmap(74, 51, wind_icon, 10, 10, WHITE); + display.display(); + + http.end(); + dataFetched = true; + break; // Exit the loop after successful fetch + } + } + // Display attempt number + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.printf("Attempt %d/3", attempt + 1); + display.display(); + delay(1000); // Retry after 1 second if the request fails + } + + if (!dataFetched) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.printf("Failed to fetch weather"); + display.display(); + http.end(); + return false; // Return false if the fetch fails after 3 attempts + } + + // Request forecast + for (attempt = 0; attempt < 3; attempt++) { + // Request forecast + http.begin(client, "http://api.openweathermap.org/data/2.5/forecast?q=" + Location + "&APPID=" + API_Key + "&cnt=3"); + int httpCode = http.GET(); + if (httpCode > 0) { + String payload = http.getString(); + DynamicJsonDocument forecastDoc(1024); + + DeserializationError error = deserializeJson(forecastDoc, payload); + if (!error) { + int pos = 69; + for (int day = 0; day <= 2; day++) { + int state = forecastDoc["list"][day]["weather"][0]["id"]; + + // Determine icon based on state ID + if (state >= 200 && state <= 232) { + icon = 6; // Thunderstorm + } else if (state >= 300 && state <= 321) { + icon = 5; // Drizzle + } else if (state >= 500 && state <= 531) { + icon = 3; // Rain + } else if (state >= 600 && state <= 622) { + icon = 4; // Snow + } else if (state >= 701 && state <= 781) { + icon = 2; // Mist + } else if (state == 800) { + icon = 0; // Clear + } else if (state >= 801 && state <= 804) { + icon = 1; // Clouds + } else { + icon = 0; // Default icon + } + + display.drawBitmap(pos, 0, bitmap_icons[icon], 16, 16, WHITE); + pos += 20; + display.display(); + } + http.end(); + break; // Exit the loop after successful fetch + } + } + // Display attempt number for forecast + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.printf("Forecast attempt %d/3", attempt + 1); + display.display(); + delay(1000); // Retry after 1 second if the request fails + } + + if (!dataFetched) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.printf("Failed to fetch forecast"); + display.display(); + return false; // Return false if the forecast fetch fails after 3 attempts + } + + return true; // Return true if both data fetches were successful +} + +void setup(void) { + initSystems(); + netman.start(); + timeClient.begin(); + delay(500); + timeClient.setTimeOffset(3600); +} + +void loop() { + timeClient.update(); + + if (!is_display_off && (millis() - lastWeatherUpdate > weatherUpdateInterval)) { + fetchWeatherData(); + lastWeatherUpdate = millis(); + } + powerSaveCheck(); + commonButtonHandler(); +} diff --git a/src/netman.h b/src/netman.h new file mode 100644 index 0000000..d8c131a --- /dev/null +++ b/src/netman.h @@ -0,0 +1,333 @@ +#include +#include +#include +#include +#include +#include +#include + +// Constants for the HTML pages and config file +static const String beginHtml = "SmartCube Configure

Configure AP


"; +static const String endHtml = "
"; +static const String configFile = "/netman"; + +// Timeout for WiFi connection attempts +static const unsigned long connectionTimeout = 15000; // 15 seconds + +struct netman { +public: + netman(Adafruit_SSD1306& display); + netman(String ssid, String pass, bool hidden, Adafruit_SSD1306& display); + bool start(); + void reset(); + void addSsid(String ssid, String password); + void removeSsid(String ssid, String password); + +private: + Adafruit_SSD1306& display; + std::unique_ptr server; + std::map _ssids; + String _ssid, _pass; + bool _hidden; + + void init(String ssid, String pass, bool hidden); + bool tryConnectToSsid(const char* ssid, const char* pass); + bool tryConnect(); + void createAP(); + bool redirectToIp(); + void readConfig(); + void writeConfig(); + void handleRoot(); + void handleAdd(); + void handleRemove(); + void handleSelect(); +}; + +// Initialization +void netman::init(String ssid, String pass, bool hidden) { + // Ensure password meets minimum length + if (pass != "" && pass.length() < 8) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Password too short"); + display.display(); + pass = "8characters"; + } + + // Get the last 4 characters of the MAC address + String macAddress = WiFi.macAddress(); + String macSuffix = macAddress.substring(macAddress.length() - 5); + macSuffix.replace(":", ""); + + // Set default SSID if none provided + _ssid = ssid.isEmpty() ? "SmartCube_" + macSuffix : ssid; + _pass = pass; + _hidden = hidden; + + + // Initialize LittleFS with error handling + if (!LittleFS.begin()) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("FS init failed"); + display.display(); + } +} + +// Constructors +netman::netman(Adafruit_SSD1306& display) : display(display) { + init("", "", false); +} + +netman::netman(String ssid, String pass, bool hidden, Adafruit_SSD1306& display) : display(display) { + init(ssid, pass, hidden); +} + +// Attempt to start and connect or create AP +bool netman::start() { + if (_pass == "8characters") { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Using default pass:"); + display.println("8characters"); + display.display(); + } + return tryConnect() || (createAP(), false); +} + +// Attempt connection to each saved SSID in order +bool netman::tryConnect() { + readConfig(); + for (auto const& item : _ssids) { + if (tryConnectToSsid(item.first.c_str(), item.second.c_str())) { + return true; + } + } + return false; +} + +// Attempt to connect to a specific SSID with timeout, dot animation, and IP display +bool netman::tryConnectToSsid(const char* ssid, const char* pass) { + WiFi.begin(ssid, pass); + unsigned long start = millis(); + + // Clear display and set initial message + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Connecting to WiFi:"); + display.setCursor(0, 11); + display.println(String(ssid)); + display.display(); + + int dotCount = 0; + while (millis() - start < connectionTimeout) { + delay(500); + + // Check WiFi connection status + if (WiFi.status() == WL_CONNECTED) { + // Success message with IP address + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Connected!"); + display.setCursor(0, 12); + display.print("IP: "); + display.println(WiFi.localIP()); + display.display(); + delay(500); + return true; + } + + // Animate by adding dots + display.setCursor(0, 20); + for (int i = 0; i < dotCount; i++) { + display.print("."); + } + display.display(); + dotCount = (dotCount + 1); + } + + // Connection failed + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Connection failed."); + display.display(); + WiFi.disconnect(); + return false; +} + +// Setup Access Point with DNS and HTTP server +void netman::createAP() { + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_AP); + WiFi.softAP(_ssid.c_str(), _pass.c_str(), 1, _hidden); + + display.clearDisplay(); + display.setCursor(0, 0); + display.println("AccessPoint created"); + display.setCursor(0, 14); + display.println("SSID:"); + display.setCursor(0, 24); + display.setTextSize(1); + display.println(_ssid.c_str()); + display.setTextSize(1); + display.setCursor(0, 40); + display.println("Config portal:"); + display.setCursor(0, 50); + display.print("http://"); + display.println(WiFi.softAPIP().toString()); + display.display(); + + server.reset(new ESP8266WebServer(80)); + DNSServer dnsServer; + dnsServer.start(53, "*", WiFi.softAPIP()); + + server->on("/", std::bind(&netman::handleRoot, this)); + server->on("/add", std::bind(&netman::handleAdd, this)); + server->on("/remove", std::bind(&netman::handleRemove, this)); + server->on("/select", std::bind(&netman::handleSelect, this)); + + server->begin(); + + while (true) { + dnsServer.processNextRequest(); + server->handleClient(); + delay(10); + } +} + +// Redirect to AP IP if not accessed directly +bool netman::redirectToIp() { + if (server->hostHeader() == WiFi.softAPIP().toString()) { + return false; + } + server->sendHeader("Location", "http://" + WiFi.softAPIP().toString(), true); + server->send(302, "text/plain", ""); + server->client().stop(); + return true; +} + +// Modify the addSsid function to take parameters from the `select` page +void netman::addSsid(String ssid, String password) { + _ssids[ssid] = password; + writeConfig(); + + // Attempt to connect to the selected network + if (tryConnectToSsid(ssid.c_str(), password.c_str())) { + // Redirect to the main page on success + server->sendHeader("Location", "/", true); + server->send(302, "text/plain", ""); + } else { + // Show error message if connection failed + server->send(200, "text/html", "

Connection failed. Please try again.

"); + } +} + +// Remove SSID from config +void netman::removeSsid(String ssid, String password) { + if (_ssids.count(ssid) && _ssids[ssid] == password) { + _ssids.erase(ssid); + writeConfig(); + } +} + +// Handle file-based config loading +void netman::readConfig() { + _ssids.clear(); + File file = LittleFS.open(configFile, "r"); + if (!file) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("Config not found"); + display.display(); + return; + } + + while (file.available()) { + String ssid = file.readStringUntil('\n'); + ssid.trim(); + String pass = file.readStringUntil('\n'); + pass.trim(); + _ssids[ssid] = pass; + } + file.close(); +} + +// Handle file-based config saving +void netman::writeConfig() { + File file = LittleFS.open(configFile, "w"); + for (const auto& item : _ssids) { + file.println(item.first); + file.println(item.second); + } + file.close(); +} + +// Reset configuration +void netman::reset() { + LittleFS.remove(configFile); + _ssids.clear(); +} + +String urlEncode(const String &str) { + String encoded = ""; + char c; + for (size_t i = 0; i < str.length(); i++) { + c = str.charAt(i); + if (isalnum(c)) { + encoded += c; + } else { + encoded += '%'; + char buf[3]; + snprintf(buf, sizeof(buf), "%02X", c); + encoded += buf; + } + } + return encoded; +} + +void netman::handleRoot() { + if (redirectToIp()) return; + + // Scan for available networks + int n = WiFi.scanNetworks(); + String result = beginHtml; + + // Add stored SSIDs to the page + result += "

Saved Networks

"; + for (const auto& item : _ssids) { + result += "" + item.first + "-" + item.second + ""; + } + + // Display available WiFi networks + result += "

Available Networks

"; + for (int i = 0; i < n; i++) { + // Get SSID and signal strength + String ssid = WiFi.SSID(i); + int rssi = WiFi.RSSI(i); + bool openNetwork = (WiFi.encryptionType(i) == ENC_TYPE_NONE); + + // Show network with button to select + result += ""; + } + result += endHtml; + server->send(200, "text/html", result); +} + +void netman::handleAdd() { + server->send(200, "text/html", "The ESP will now reboot."); + addSsid(server->arg("ssid"), server->arg("pass")); + delay(500); + ESP.restart(); +} + +void netman::handleRemove() { + removeSsid(server->arg("ssid"), server->arg("pass")); + handleRoot(); +} + +// Add SSID to config and save +void netman::handleSelect() { + String ssid = server->arg("ssid"); + String selectPage = "Connect to " + ssid + "

Connect to " + ssid + "

"; + server->send(200, "text/html", selectPage); +} \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
" + (openNetwork ? "(Open)" : "(Secured)") + "" + String(rssi) + " dBm